initial commit

This commit is contained in:
2026-04-04 22:13:55 -04:00
commit 5d77e207c9
10181 changed files with 522212 additions and 0 deletions

View File

@@ -0,0 +1,165 @@
import { Router, Request, Response } from "express";
import { storage } from "../storage";
import { prisma } from "@repo/db/client";
import {
insertAppointmentProcedureSchema,
updateAppointmentProcedureSchema,
} from "@repo/db/types";
const router = Router();
/**
* GET /api/appointment-procedures/:appointmentId
* Get all procedures for an appointment
*/
router.get("/:appointmentId", async (req: Request, res: Response) => {
try {
const appointmentId = Number(req.params.appointmentId);
if (isNaN(appointmentId)) {
return res.status(400).json({ message: "Invalid appointmentId" });
}
const rows = await storage.getByAppointmentId(appointmentId);
return res.json(rows);
} catch (err: any) {
console.error("GET appointment procedures error", err);
return res.status(500).json({ message: err.message ?? "Server error" });
}
});
router.get(
"/prefill-from-appointment/:appointmentId",
async (req: Request, res: Response) => {
try {
const appointmentId = Number(req.params.appointmentId);
if (!appointmentId || isNaN(appointmentId)) {
return res.status(400).json({ error: "Invalid appointmentId" });
}
const data = await storage.getPrefillDataByAppointmentId(appointmentId);
if (!data) {
return res.status(404).json({ error: "Appointment not found" });
}
return res.json(data);
} catch (err: any) {
console.error("prefill-from-appointment error", err);
return res
.status(500)
.json({ error: err.message ?? "Failed to prefill claim data" });
}
}
);
/**
* POST /api/appointment-procedures
* Add single manual procedure
*/
router.post("/", async (req: Request, res: Response) => {
try {
const parsed = insertAppointmentProcedureSchema.parse(req.body);
const created = await storage.createProcedure(parsed);
return res.json(created);
} catch (err: any) {
console.error("POST appointment procedure error", err);
if (err.name === "ZodError") {
return res.status(400).json({ message: err.errors });
}
return res.status(500).json({ message: err.message ?? "Server error" });
}
});
/**
* POST /api/appointment-procedures/bulk
* Add multiple procedures (combos)
*/
router.post("/bulk", async (req: Request, res: Response) => {
try {
const rows = req.body;
if (!Array.isArray(rows) || rows.length === 0) {
return res.status(400).json({ message: "Invalid payload" });
}
const count = await storage.createProceduresBulk(rows);
return res.json({ success: true, count });
} catch (err: any) {
console.error("POST bulk appointment procedures error", err);
return res.status(500).json({ message: err.message ?? "Server error" });
}
});
/**
* PUT /api/appointment-procedures/:id
* Update a procedure
*/
router.put("/:id", async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
if (isNaN(id)) {
return res.status(400).json({ message: "Invalid id" });
}
const parsed = updateAppointmentProcedureSchema.parse(req.body);
const updated = await storage.updateProcedure(id, parsed);
return res.json(updated);
} catch (err: any) {
console.error("PUT appointment procedure error", err);
if (err.name === "ZodError") {
return res.status(400).json({ message: err.errors });
}
return res.status(500).json({ message: err.message ?? "Server error" });
}
});
/**
* DELETE /api/appointment-procedures/:id
* Delete single procedure
*/
router.delete("/:id", async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
if (isNaN(id)) {
return res.status(400).json({ message: "Invalid id" });
}
await storage.deleteProcedure(id);
return res.json({ success: true });
} catch (err: any) {
console.error("DELETE appointment procedure error", err);
return res.status(500).json({ message: err.message ?? "Server error" });
}
});
/**
* DELETE /api/appointment-procedures/clear/:appointmentId
* Clear all procedures for appointment
*/
router.delete("/clear/:appointmentId", async (req: Request, res: Response) => {
try {
const appointmentId = Number(req.params.appointmentId);
if (isNaN(appointmentId)) {
return res.status(400).json({ message: "Invalid appointmentId" });
}
await storage.clearByAppointmentId(appointmentId);
return res.json({ success: true });
} catch (err: any) {
console.error("CLEAR appointment procedures error", err);
return res.status(500).json({ message: err.message ?? "Server error" });
}
});
export default router;

View File

@@ -0,0 +1,439 @@
import { Router } from "express";
import type { Request, Response } from "express";
import { storage } from "../storage";
import { z } from "zod";
import {
insertAppointmentSchema,
updateAppointmentSchema,
} from "@repo/db/types";
const router = Router();
// Get all appointments
router.get("/all", async (req: Request, res: Response): Promise<any> => {
try {
const appointments = await storage.getAllAppointments();
res.json(appointments);
} catch (error) {
res.status(500).json({ message: "Failed to retrieve all appointments" });
}
});
/**
* GET /api/appointments/day?date=YYYY-MM-DD
* Response: { appointments: Appointment[], patients: Patient[] }
*/
router.get("/day", async (req: Request, res: Response): Promise<any> => {
function isValidYMD(s: string) {
return /^\d{4}-\d{2}-\d{2}$/.test(s);
}
try {
const rawDate = req.query.date as string | undefined;
if (!rawDate || !isValidYMD(rawDate)) {
return res.status(400).json({ message: "Date query param is required." });
}
if (!req.user) return res.status(401).json({ message: "Unauthorized" });
// Build literal UTC day bounds from the YYYY-MM-DD query string
const start = new Date(`${rawDate}T00:00:00.000Z`);
const end = new Date(`${rawDate}T23:59:59.999Z`);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
return res.status(400).json({ message: "Invalid date format" });
}
// Call the storage method that takes a start/end range (no change to storage needed)
const appointments = await storage.getAppointmentsOnRange(start, end);
// dedupe patient ids referenced by those appointments
const patientIds = Array.from(
new Set(appointments.map((a) => a.patientId).filter(Boolean))
);
const patients = patientIds.length
? await storage.getPatientsByIds(patientIds)
: [];
return res.json({ appointments, patients });
} catch (err) {
console.error("Error in /api/appointments/day:", err);
res.status(500).json({ message: "Failed to load appointments for date" });
}
});
// Get recent appointments (paginated)
router.get("/recent", async (req: Request, res: Response) => {
try {
const limit = Math.max(1, parseInt(req.query.limit as string) || 10);
const offset = Math.max(0, parseInt(req.query.offset as string) || 0);
const all = await storage.getRecentAppointments(limit, offset);
res.json({ data: all, limit, offset });
} catch (err) {
res.status(500).json({ message: "Failed to get recent appointments" });
}
});
// Get a single appointment by ID
router.get(
"/:id",
async (req: Request, res: Response): Promise<any> => {
try {
const appointmentIdParam = req.params.id;
// Ensure that patientIdParam exists and is a valid number
if (!appointmentIdParam) {
return res.status(400).json({ message: "Appointment ID is required" });
}
const appointmentId = parseInt(appointmentIdParam);
const appointment = await storage.getAppointment(appointmentId);
if (!appointment) {
return res.status(404).json({ message: "Appointment not found" });
}
res.json(appointment);
} catch (error) {
res.status(500).json({ message: "Failed to retrieve appointment" });
}
}
);
// Get all appointments for a specific patient
router.get(
"/:patientId/appointments",
async (req: Request, res: Response): Promise<any> => {
try {
const rawPatientId = req.params.patientId;
if (!rawPatientId) {
return res.status(400).json({ message: "Patient ID is required" });
}
const patientId = parseInt(rawPatientId);
if (isNaN(patientId)) {
return res.status(400).json({ message: "Invalid patient ID" });
}
const patient = await storage.getPatient(patientId);
if (!patient)
return res.status(404).json({ message: "Patient not found" });
const appointments = await storage.getAppointmentsByPatientId(patientId);
res.json(appointments);
} catch (err) {
res.status(500).json({ message: "Failed to get patient appointments" });
}
}
);
/**
* GET /api/appointments/:id/patient
*/
router.get(
"/:id/patient",
async (req: Request, res: Response): Promise<any> => {
try {
const rawId = req.params.id;
if (!rawId) {
return res.status(400).json({ message: "Appointment ID is required" });
}
const apptId = parseInt(rawId, 10);
if (Number.isNaN(apptId) || apptId <= 0) {
return res.status(400).json({ message: "Invalid appointment ID" });
}
const patient = await storage.getPatientFromAppointmentId(apptId);
if (!patient) {
return res
.status(404)
.json({ message: "Patient not found for the given appointment" });
}
return res.json(patient);
} catch (err) {
return res
.status(500)
.json({ message: "Failed to retrieve patient for appointment" });
}
}
);
// Create a new appointment
router.post(
"/upsert",
async (req: Request, res: Response): Promise<any> => {
try {
// Validate request body
const appointmentData = insertAppointmentSchema.parse({
...req.body,
userId: req.user!.id,
});
const originalStartTime = appointmentData.startTime;
const MAX_END_TIME = "18:30";
// 1. Verify patient exists and belongs to user
const patient = await storage.getPatient(appointmentData.patientId);
if (!patient) {
return res.status(404).json({ message: "Patient not found" });
}
// 2. Attempt to find the next available slot
let [hour, minute] = originalStartTime.split(":").map(Number);
const pad = (n: number) => n.toString().padStart(2, "0");
// Step by 15 minutes to support quarter-hour starts, but keep appointment duration 30 mins
const STEP_MINUTES = 15;
const APPT_DURATION_MINUTES = 30;
while (`${pad(hour)}:${pad(minute)}` <= MAX_END_TIME) {
const currentStartTime = `${pad(hour)}:${pad(minute)}`;
// Check patient appointment at this time
const sameDayAppointment =
await storage.getPatientAppointmentByDateTime(
appointmentData.patientId,
appointmentData.date,
currentStartTime
);
// Check staff conflict at this time
const staffConflict = await storage.getStaffAppointmentByDateTime(
appointmentData.staffId,
appointmentData.date,
currentStartTime,
sameDayAppointment?.id // Ignore self if updating
);
if (!staffConflict) {
const endMinute = minute + APPT_DURATION_MINUTES;
let endHour = hour + Math.floor(endMinute / 60);
let realEndMinute = endMinute % 60;
const currentEndTime = `${pad(endHour)}:${pad(realEndMinute)}`;
const payload = {
...appointmentData,
startTime: currentStartTime,
endTime: currentEndTime,
};
let responseData;
if (sameDayAppointment?.id !== undefined) {
const updated = await storage.updateAppointment(
sameDayAppointment.id,
payload
);
responseData = {
...updated,
originalRequestedTime: originalStartTime,
finalScheduledTime: currentStartTime,
message:
originalStartTime !== currentStartTime
? `Your requested time (${originalStartTime}) was unavailable. Appointment was updated to ${currentStartTime}.`
: `Appointment successfully updated at ${currentStartTime}.`,
};
return res.status(200).json(responseData);
}
const created = await storage.createAppointment(payload);
responseData = {
...created,
originalRequestedTime: originalStartTime,
finalScheduledTime: currentStartTime,
message:
originalStartTime !== currentStartTime
? `Your requested time (${originalStartTime}) was unavailable. Appointment was scheduled at ${currentStartTime}.`
: `Appointment successfully scheduled at ${currentStartTime}.`,
};
return res.status(201).json(responseData);
}
// Move to next STEP_MINUTES slot
minute += STEP_MINUTES;
if (minute >= 60) {
hour += Math.floor(minute / 60);
minute = minute % 60;
}
}
return res.status(409).json({
message:
"No available slots remaining until 6:30 PM for this Staff. Please choose another day.",
});
} catch (error) {
console.error("Error in upsert appointment:", error);
if (error instanceof z.ZodError) {
console.log(
"Validation error details:",
JSON.stringify(error.format(), null, 2)
);
return res.status(400).json({
message: "Validation error",
errors: error.format(),
});
}
res.status(500).json({
message: "Failed to upsert appointment",
error: error instanceof Error ? error.message : String(error),
});
}
}
);
// Update an existing appointment
router.put(
"/:id",
async (req: Request, res: Response): Promise<any> => {
try {
const appointmentData = updateAppointmentSchema.parse({
...req.body,
userId: req.user!.id,
});
const appointmentIdParam = req.params.id;
if (!appointmentIdParam) {
return res.status(400).json({ message: "Appointment ID is required" });
}
const appointmentId = parseInt(appointmentIdParam);
// 1. Verify patient exists and belongs to user
const patient = await storage.getPatient(appointmentData.patientId);
if (!patient) {
return res.status(404).json({ message: "Patient not found" });
}
// 2. Check if appointment exists and belongs to user
const existingAppointment = await storage.getAppointment(appointmentId);
if (!existingAppointment) {
console.log("Appointment not found:", appointmentId);
return res.status(404).json({ message: "Appointment not found" });
}
// 4. Reject patientId change (not allowed)
if (
appointmentData.patientId &&
appointmentData.patientId !== existingAppointment.patientId
) {
return res
.status(400)
.json({ message: "Changing patientId is not allowed" });
}
// 5. Check for conflicting appointments (same patient OR staff at same time)
const date = appointmentData.date ?? existingAppointment.date;
const startTime =
appointmentData.startTime ?? existingAppointment.startTime;
const staffId = appointmentData.staffId ?? existingAppointment.staffId;
const patientConflict = await storage.getPatientConflictAppointment(
existingAppointment.patientId,
date,
startTime,
appointmentId
);
if (patientConflict) {
return res.status(409).json({
message: "This patient already has an appointment at this time.",
});
}
const staffConflict = await storage.getStaffConflictAppointment(
staffId,
date,
startTime,
appointmentId
);
if (staffConflict) {
return res.status(409).json({
message: "This time slot is already booked for the selected staff.",
});
}
// 6. if date gets updated, then also update the aptmnt status to unknown.
// Normalize to YYYY-MM-DD to avoid timezone problems (model uses @db.Date)
const oldYMD = new Date(existingAppointment.date)
.toISOString()
.slice(0, 10);
const newYMD = new Date(date).toISOString().slice(0, 10);
const isDateChanged = oldYMD !== newYMD;
const updatePayload = {
...appointmentData,
...(isDateChanged ? { eligibilityStatus: "UNKNOWN" as const } : {}),
};
// Update appointment
const updatedAppointment = await storage.updateAppointment(
appointmentId,
updatePayload
);
return res.json(updatedAppointment);
} catch (error) {
console.error("Error updating appointment:", error);
if (error instanceof z.ZodError) {
console.log(
"Validation error details:",
JSON.stringify(error.format(), null, 2)
);
return res.status(400).json({
message: "Validation error",
errors: error.format(),
});
}
res.status(500).json({
message: "Failed to update appointment",
error: error instanceof Error ? error.message : String(error),
});
}
}
);
// Delete an appointment
router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
try {
const appointmentIdParam = req.params.id;
if (!appointmentIdParam) {
return res.status(400).json({ message: "Appointment ID is required" });
}
const appointmentId = parseInt(appointmentIdParam);
// Check if appointment exists and belongs to user
const existingAppointment = await storage.getAppointment(appointmentId);
if (!existingAppointment) {
return res.status(404).json({ message: "Appointment not found" });
}
if (existingAppointment.userId !== req.user!.id) {
return res.status(403).json({
message:
"Forbidden: Appointment belongs to a different user, you can't delete this.",
});
}
// Delete appointment
await storage.deleteAppointment(appointmentId);
res.status(204).send();
} catch (error) {
res.status(500).json({ message: "Failed to delete appointment" });
}
});
export default router;

99
apps/Backend/src/routes/auth.ts Executable file
View File

@@ -0,0 +1,99 @@
import express, { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";
import { storage } from "../storage";
import { UserUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
import { z } from "zod";
type SelectUser = z.infer<typeof UserUncheckedCreateInputObjectSchema>;
const JWT_SECRET = process.env.JWT_SECRET || "your-jwt-secret";
const JWT_EXPIRATION = "24h"; // JWT expiration time (1 day)
// Function to hash password using bcrypt
async function hashPassword(password: string) {
const saltRounds = 10; // Salt rounds for bcrypt
const hashedPassword = await bcrypt.hash(password, saltRounds);
return hashedPassword;
}
// Function to compare passwords using bcrypt
async function comparePasswords(supplied: string, stored: string) {
const isMatch = await bcrypt.compare(supplied, stored);
return isMatch;
}
// Function to generate JWT
function generateToken(user: SelectUser) {
return jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, {
expiresIn: JWT_EXPIRATION,
});
}
const router = express.Router();
// User registration route
router.post(
"/register",
async (req: Request, res: Response, next: NextFunction): Promise<any> => {
try {
const existingUser = await storage.getUserByUsername(req.body.username);
if (existingUser) {
return res.status(400).send("Username already exists");
}
const hashedPassword = await hashPassword(req.body.password);
const user = await storage.createUser({
...req.body,
password: hashedPassword,
});
// Generate a JWT token for the user after successful registration
const token = generateToken(user);
const { password, ...safeUser } = user;
return res.status(201).json({ user: safeUser, token });
} catch (error) {
console.error("Registration error:", error);
return res.status(500).json({ error: "Internal server error" });
}
}
);
// User login route
router.post(
"/login",
async (req: Request, res: Response, next: NextFunction): Promise<any> => {
try {
const user = await storage.getUserByUsername(req.body.username);
if (!user) {
return res.status(401).json({ error: "Invalid username or password" });
}
const isPasswordMatch = await comparePasswords(
req.body.password,
user.password
);
if (!isPasswordMatch) {
return res.status(401).json({ error: "Invalid password or password" });
}
// Generate a JWT token for the user after successful login
const token = generateToken(user);
const { password, ...safeUser } = user;
return res.status(200).json({ user: safeUser, token });
} catch (error) {
return res.status(500).json({ error: "Internal server error" });
}
}
);
// Logout route (client-side action to remove the token)
router.post("/logout", (req: Request, res: Response) => {
// For JWT-based auth, logout is handled on the client (by removing token)
res.status(200).send("Logged out successfully");
});
export default router;

578
apps/Backend/src/routes/claims.ts Executable file
View File

@@ -0,0 +1,578 @@
import { Router } from "express";
import { Request, Response } from "express";
import { storage } from "../storage";
import { z } from "zod";
import multer from "multer";
import { forwardToSeleniumClaimAgent } from "../services/seleniumClaimClient";
import path from "path";
import axios from "axios";
import { Prisma } from "@repo/db/generated/prisma";
import { Decimal } from "decimal.js";
import {
ExtendedClaimSchema,
InputServiceLine,
updateClaimSchema,
} from "@repo/db/types";
import { forwardToSeleniumClaimPreAuthAgent } from "../services/seleniumInsuranceClaimPreAuthClient";
const router = Router();
// Routes
const multerStorage = multer.memoryStorage(); // NO DISK
const upload = multer({
storage: multerStorage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB limit per file
fileFilter: (req, file, cb) => {
const allowed = [
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error("Unsupported file type"));
}
},
});
router.post(
"/mh-provider-login",
async (req: Request, res: Response): Promise<any> => {
if (!req.user || !req.user.id) {
return res.status(401).json({ error: "Unauthorized: user info missing" });
}
try {
const { memberId, dateOfBirth, submissionDate, firstName, lastName, procedureCode, toothNumber, toothSurface, insuranceSiteKey } = req.body;
if (!memberId || !dateOfBirth || !submissionDate || !firstName || !lastName || !procedureCode || !insuranceSiteKey) {
return res.status(400).json({ error: "Missing required fields: memberId, dateOfBirth, submissionDate, firstName, lastName, procedureCode, insuranceSiteKey" });
}
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
req.user.id,
insuranceSiteKey
);
if (!credentials) {
return res.status(404).json({
error:
"No insurance credentials found for this provider. Kindly Update this at Settings Page.",
});
}
const enrichedData = {
data: {
memberId,
dateOfBirth,
submissionDate,
firstName,
lastName,
procedureCode,
toothNumber,
toothSurface,
insuranceSiteKey,
massdhpUsername: credentials.username,
massdhpPassword: credentials.password,
},
};
const seleniumRes = await axios.post(
"http://localhost:5002/claims-login",
enrichedData
);
const result = seleniumRes.data;
if (result?.status !== "success") {
return res.status(502).json({ error: result?.message || "Selenium service error" });
}
return res.json({ status: "success", message: "Claims automation completed. Browser remains open." });
} catch (err: any) {
console.error(err);
return res.status(500).json({
error: err?.message || "Failed to contact selenium service",
});
}
}
);
router.post(
"/selenium-claim",
upload.fields([
{ name: "pdfs", maxCount: 10 },
{ name: "images", maxCount: 10 },
]),
async (req: Request, res: Response): Promise<any> => {
if (!req.files || !req.body.data) {
return res
.status(400)
.json({ error: "Missing files or claim data for selenium" });
}
if (!req.user || !req.user.id) {
return res.status(401).json({ error: "Unauthorized: user info missing" });
}
try {
const claimData = JSON.parse(req.body.data);
const pdfs =
(req.files as Record<string, Express.Multer.File[]>).pdfs ?? [];
const images =
(req.files as Record<string, Express.Multer.File[]>).images ?? [];
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
req.user.id,
claimData.insuranceSiteKey
);
if (!credentials) {
return res.status(404).json({
error:
"No insurance credentials found for this provider. Kindly Update this at Settings Page.",
});
}
const enrichedData = {
...claimData,
massdhpUsername: credentials.username,
massdhpPassword: credentials.password,
};
const result = await forwardToSeleniumClaimAgent(enrichedData, [
...pdfs,
...images,
]);
// Store claimNumber if returned from Selenium
if (result?.claimNumber && claimData.claimId) {
try {
await storage.updateClaim(claimData.claimId, {
claimNumber: result.claimNumber,
});
console.log(`Updated claim ${claimData.claimId} with claimNumber: ${result.claimNumber}`);
} catch (updateErr) {
console.error("Failed to update claim with claimNumber:", updateErr);
}
}
res.json({
...result,
claimId: claimData.claimId,
});
} catch (err: any) {
console.error(err);
return res.status(500).json({
error: err.message || "Failed to forward to selenium agent",
});
}
}
);
router.post(
"/selenium/fetchpdf",
async (req: Request, res: Response): Promise<any> => {
function sendError(res: Response, message: string, status = 400) {
console.error("Error:", message);
return res.status(status).json({ error: message });
}
try {
if (!req.user || !req.user.id) {
return sendError(res, "Unauthorized: user info missing", 401);
}
const { patientId, pdf_url, groupTitleKey } = req.body;
if (!pdf_url) {
return sendError(res, "Missing pdf_url");
}
if (!patientId) {
return sendError(res, "Missing Patient Id");
}
const parsedPatientId = parseInt(patientId);
console.log("Fetching PDF from URL:", pdf_url);
const filename = path.basename(new URL(pdf_url).pathname);
console.log("Extracted filename:", filename);
// Always fetch from localhost regardless of what hostname is in the pdf_url,
// since both backend and selenium service run on the same machine.
const seleniumPort = process.env.SELENIUM_PORT || "5002";
const localPdfUrl = `http://localhost:${seleniumPort}/downloads/${filename}`;
console.log("Fetching PDF from local URL:", localPdfUrl);
const pdfResponse = await axios.get(localPdfUrl, {
responseType: "arraybuffer",
timeout: 15000,
});
console.log("PDF fetched successfully, size:", pdfResponse.data.length);
// Allowed keys as a literal tuple to derive a union type
const allowedKeys = [
"INSURANCE_CLAIM",
"INSURANCE_CLAIM_PREAUTH",
] as const;
type GroupKey = (typeof allowedKeys)[number];
const isGroupKey = (v: any): v is GroupKey =>
(allowedKeys as readonly string[]).includes(v);
if (!isGroupKey(groupTitleKey)) {
return sendError(
res,
`Invalid groupTitleKey. Must be one of: ${allowedKeys.join(", ")}`
);
}
const GROUP_TITLES: Record<GroupKey, string> = {
INSURANCE_CLAIM: "Claims",
INSURANCE_CLAIM_PREAUTH: "Claims Preauth",
};
const groupTitle = GROUP_TITLES[groupTitleKey];
// ✅ Find or create PDF group for this claim
let group = await storage.findPdfGroupByPatientTitleKey(
parsedPatientId,
groupTitleKey
);
if (!group) {
group = await storage.createPdfGroup(
parsedPatientId,
groupTitle,
groupTitleKey
);
}
// ✅ Save PDF file into that group
const created = await storage.createPdfFile(group.id!, filename, pdfResponse.data);
// Extract the PDF file ID for opening the viewer
let pdfFileId: number | null = null;
if (created && typeof created === "object" && "id" in created) {
pdfFileId = Number(created.id);
} else if (typeof created === "number") {
pdfFileId = created;
}
return res.json({
success: true,
pdfPath: `/temp/${filename}`,
pdf_url,
fileName: filename,
pdfFileId,
// pdfFilename: filename,
});
} catch (err: any) {
console.error("Error in /selenium/fetchpdf:", err);
console.error("Error details:", {
message: err.message,
code: err.code,
response: err.response?.status,
responseData: err.response?.data,
});
const errorMsg = err.response?.data || err.message || "Failed to Fetch and Download the pdf";
return sendError(res, `Failed to Fetch and Download the pdf: ${errorMsg}`, 500);
}
}
);
router.post(
"/selenium-claim-pre-auth",
upload.fields([
{ name: "pdfs", maxCount: 10 },
{ name: "images", maxCount: 10 },
]),
async (req: Request, res: Response): Promise<any> => {
if (!req.files || !req.body.data) {
return res
.status(400)
.json({ error: "Missing files or claim data for selenium" });
}
if (!req.user || !req.user.id) {
return res.status(401).json({ error: "Unauthorized: user info missing" });
}
try {
const claimData = JSON.parse(req.body.data);
const pdfs =
(req.files as Record<string, Express.Multer.File[]>).pdfs ?? [];
const images =
(req.files as Record<string, Express.Multer.File[]>).images ?? [];
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
req.user.id,
claimData.insuranceSiteKey
);
if (!credentials) {
return res.status(404).json({
error:
"No insurance credentials found for this provider. Kindly Update this at Settings Page.",
});
}
const enrichedData = {
...claimData,
massdhpUsername: credentials.username,
massdhpPassword: credentials.password,
};
const result = await forwardToSeleniumClaimPreAuthAgent(enrichedData, [
...pdfs,
...images,
]);
res.json({
...result,
claimId: claimData.claimId,
});
} catch (err: any) {
console.error(err);
return res.status(500).json({
error: err.message || "Failed to forward to selenium agent",
});
}
}
);
// GET /api/claims/recent
router.get("/recent", async (req: Request, res: Response) => {
try {
const limit = parseInt(req.query.limit as string) || 10;
const offset = parseInt(req.query.offset as string) || 0;
const [claims, totalCount] = await Promise.all([
storage.getRecentClaims(limit, offset),
storage.getTotalClaimCount(),
]);
res.json({ claims, totalCount });
} catch (error) {
console.error("Failed to retrieve recent claims:", error);
res.status(500).json({ message: "Failed to retrieve recent claims" });
}
});
// GET /api/claims/patient/:patientId
router.get(
"/patient/:patientId",
async (req: Request, res: Response): Promise<any> => {
try {
const patientIdParam = Array.isArray(req.params.patientId) ? req.params.patientId[0] : req.params.patientId;
if (!patientIdParam) {
return res.status(400).json({ message: "Missing patientId" });
}
const patientId = parseInt(patientIdParam);
if (isNaN(patientId)) {
return res.status(400).json({ message: "Invalid patientId" });
}
const limit = parseInt(req.query.limit as string) || 10;
const offset = parseInt(req.query.offset as string) || 0;
if (isNaN(patientId)) {
return res.status(400).json({ message: "Invalid patient ID" });
}
const [claims, totalCount] = await Promise.all([
storage.getRecentClaimsByPatientId(patientId, limit, offset),
storage.getTotalClaimCountByPatient(patientId),
]);
res.json({ claims, totalCount });
} catch (error) {
console.error("Failed to retrieve claims for patient:", error);
res.status(500).json({ message: "Failed to retrieve patient claims" });
}
}
);
// Get all claims count.
router.get("/all", async (req: Request, res: Response) => {
try {
const claims = await storage.getTotalClaimCount();
res.json(claims);
} catch (error) {
res.status(500).json({ message: "Failed to retrieve claims count" });
}
});
// Get a single claim by ID
router.get("/:id", async (req: Request, res: Response): Promise<any> => {
try {
const idParam = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
if (!idParam) {
return res.status(400).json({ error: "Missing claim ID" });
}
const claimId = parseInt(idParam, 10);
if (isNaN(claimId)) {
return res.status(400).json({ error: "Invalid claim ID" });
}
const claim = await storage.getClaim(claimId);
if (!claim) {
return res.status(404).json({ message: "Claim not found" });
}
res.json(claim);
} catch (error) {
res.status(500).json({ message: "Failed to retrieve claim" });
}
});
// Create a new claim
router.post("/", async (req: Request, res: Response): Promise<any> => {
try {
// --- TRANSFORM claimFiles (if provided) into Prisma nested-create shape
if (Array.isArray(req.body.claimFiles)) {
// each item expected: { filename: string, mimeType: string }
req.body.claimFiles = {
create: req.body.claimFiles.map((f: any) => ({
filename: String(f.filename),
mimeType: String(f.mimeType || f.mime || ""),
})),
};
}
// --- TRANSFORM serviceLines
if (
!Array.isArray(req.body.serviceLines) ||
req.body.serviceLines.length === 0
) {
return res.status(400).json({
message: "At least one service line is required to create a claim",
});
}
if (Array.isArray(req.body.serviceLines)) {
req.body.serviceLines = req.body.serviceLines.map(
(line: InputServiceLine) => ({
...line,
totalBilled: Number(line.totalBilled),
totalAdjusted: 0,
totalPaid: 0,
totalDue: Number(line.totalBilled),
})
);
req.body.serviceLines = { create: req.body.serviceLines };
}
const parsedClaim = ExtendedClaimSchema.parse({
...req.body,
userId: req.user!.id,
});
// Step 1: Calculate total billed from service lines
const serviceLinesCreateInput = (
parsedClaim.serviceLines as Prisma.ServiceLineCreateNestedManyWithoutClaimInput
)?.create;
const lines = Array.isArray(serviceLinesCreateInput)
? (serviceLinesCreateInput as unknown as {
totalBilled: number | string;
}[])
: [];
const totalBilled = lines.reduce(
(sum, line) => sum + Number(line.totalBilled ?? 0),
0
);
// Step 2: Create claim (with service lines)
const claim = await storage.createClaim(parsedClaim);
// Step 3: Create empty payment
await storage.createPayment({
claimId: claim.id,
patientId: claim.patientId,
userId: req.user!.id,
totalBilled: new Decimal(totalBilled),
totalPaid: new Decimal(0),
totalDue: new Decimal(totalBilled),
status: "PENDING",
notes: "",
});
res.status(201).json(claim);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
message: "Validation error",
errors: error.format(),
});
}
console.error("❌ Failed to create claim:", error); // logs full error to server
// Send more detailed info to the client (for dev only)
return res.status(500).json({
message: "Failed to create claim",
error: error instanceof Error ? error.message : String(error),
});
}
});
// Update a claim
router.put("/:id", async (req: Request, res: Response): Promise<any> => {
try {
const idParam = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
if (!idParam) {
return res.status(400).json({ error: "Missing claim ID" });
}
const claimId = parseInt(idParam, 10);
if (isNaN(claimId)) {
return res.status(400).json({ error: "Invalid claim ID" });
}
const existingClaim = await storage.getClaim(claimId);
if (!existingClaim) {
return res.status(404).json({ message: "Claim not found" });
}
const claimData = updateClaimSchema.parse(req.body);
const updatedClaim = await storage.updateClaim(claimId, claimData);
res.json(updatedClaim);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
message: "Validation error",
errors: error.format(),
});
}
res.status(500).json({ message: "Failed to update claim" });
}
});
// Delete a claim
router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
try {
const idParam = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
if (!idParam) {
return res.status(400).json({ error: "Missing claim ID" });
}
const claimId = parseInt(idParam, 10);
if (isNaN(claimId)) {
return res.status(400).json({ error: "Invalid claim ID" });
}
const existingClaim = await storage.getClaim(claimId);
if (!existingClaim) {
return res.status(404).json({ message: "Claim not found" });
}
if (existingClaim.userId !== req.user!.id) {
return res.status(403).json({
message:
"Forbidden: Claim belongs to a different user, you can't delete this.",
});
}
await storage.deleteClaim(claimId);
res.status(204).send();
} catch (error) {
res.status(500).json({ message: "Failed to delete claim" });
}
});
export default router;

View File

@@ -0,0 +1,556 @@
import express, { Request, Response } from "express";
import storage from "../storage";
import { serializeFile } from "../utils/prismaFileUtils";
import { CloudFolder } from "@repo/db/types";
const router = express.Router();
/* ---------- Helpers ---------- */
function parsePositiveInt(v: unknown, fallback: number) {
const n = Number(v);
if (!Number.isFinite(n) || n < 0) return fallback;
return Math.floor(n);
}
function sendError(
res: Response,
status: number,
message: string,
details?: any
) {
return res.status(status).json({ error: true, message, details });
}
/* ---------- Paginated child FOLDERS for a parent ----------
GET /items/folders?parentId=&limit=&offset=
parentId may be "null" or numeric or absent (means root)
*/
router.get(
"/items/folders",
async (req: Request, res: Response): Promise<any> => {
const rawParent = req.query.parentId;
const parentId =
rawParent === undefined
? null
: rawParent === "null"
? null
: Number(rawParent);
if (parentId !== null && (!Number.isInteger(parentId) || parentId <= 0)) {
return sendError(res, 400, "Invalid parentId");
}
const limit = parsePositiveInt(req.query.limit, 10); // default 10 folders/page
const offset = parsePositiveInt(req.query.offset, 0);
try {
// Prefer a storage method that lists folders by parent, otherwise filter
let data: CloudFolder[] = [];
if (typeof (storage as any).listFoldersByParent === "function") {
data = await (storage as any).listFoldersByParent(
parentId,
limit,
offset
);
const total =
(await (storage as any).countFoldersByParent?.(parentId)) ??
data.length;
return res.json({ error: false, data, total, limit, offset });
}
// Fallback: use recent and filter (less efficient). Recommend implementing listFoldersByParent in storage.
const recent = await storage.listRecentFolders(1000, 0);
const folders = (recent || []).filter(
(f: any) => (f as any).parentId === parentId
);
const paged = folders.slice(offset, offset + limit);
return res.json({
error: false,
data: paged,
totalCount: folders.length,
});
} catch (err) {
return sendError(res, 500, "Failed to load child folders", err);
}
}
);
/* ---------- Paginated files for a folder ----------
GET /items/files?parentId=&limit=&offset=
parentId may be "null" or numeric or absent (means root)
*/
router.get(
"/items/files",
async (req: Request, res: Response): Promise<any> => {
const rawParent = req.query.parentId;
const parentId =
rawParent === undefined
? null
: rawParent === "null"
? null
: Number(rawParent);
if (parentId !== null && (!Number.isInteger(parentId) || parentId <= 0)) {
return sendError(res, 400, "Invalid parentId");
}
const limit = parsePositiveInt(req.query.limit, 20); // default 20 files/page
const offset = parsePositiveInt(req.query.offset, 0);
try {
const files = await storage.listFilesInFolder(parentId, limit, offset);
const totalCount = await storage.countFilesInFolder(parentId);
const serialized = files.map(serializeFile);
return res.json({ error: false, data: serialized, totalCount });
} catch (err) {
return sendError(res, 500, "Failed to load files for folder", err);
}
}
);
/* ---------- Recent folders (global) ----------
GET /folders/recent?limit=&offset=
*/
router.get(
"/folders/recent",
async (req: Request, res: Response): Promise<any> => {
const limit = parsePositiveInt(req.query.limit, 50);
const offset = parsePositiveInt(req.query.offset, 0);
try {
// Always request top-level folders (parentId = null)
const parentId: number | null = null;
const folders = await storage.listRecentFolders(limit, offset, parentId);
const totalCount = await storage.countFoldersByParent(parentId);
return res.json({
error: false,
data: folders,
totalCount,
});
} catch (err) {
return sendError(res, 500, "Failed to load recent folders");
}
}
);
// ---------- Folder CRUD ----------
router.get(
"/folders/:id",
async (req: Request, res: Response): Promise<any> => {
const id = Number.parseInt(req.params.id ?? "", 10);
if (!Number.isInteger(id) || id <= 0)
return sendError(res, 400, "Invalid folder id");
try {
const folder = await storage.getFolder(id);
if (!folder) return sendError(res, 404, "Folder not found");
return res.json({ error: false, data: folder });
} catch (err) {
return sendError(res, 500, "Failed to load folder");
}
}
);
router.post("/folders", async (req: Request, res: Response): Promise<any> => {
const { userId, name, parentId } = req.body;
if (!userId || typeof name !== "string" || !name.trim()) {
return sendError(res, 400, "Missing or invalid userId/name");
}
try {
const created = await storage.createFolder(
userId,
name.trim(),
parentId ?? null
);
return res.status(201).json({ error: false, data: created });
} catch (err) {
return sendError(res, 500, "Failed to create folder");
}
});
router.put(
"/folders/:id",
async (req: Request, res: Response): Promise<any> => {
// coerce possibly-undefined param to string before parsing
const id = Number.parseInt(req.params.id ?? "", 10);
if (!Number.isInteger(id) || id <= 0)
return sendError(res, 400, "Invalid folder id");
const updates: any = {};
if (typeof req.body.name === "string") updates.name = req.body.name.trim();
if (req.body.parentId !== undefined) updates.parentId = req.body.parentId;
try {
const updated = await storage.updateFolder(id, updates);
if (!updated)
return sendError(res, 404, "Folder not found or update failed");
return res.json({ error: false, data: updated });
} catch (err) {
return sendError(res, 500, "Failed to update folder");
}
}
);
router.delete(
"/folders/:id",
async (req: Request, res: Response): Promise<any> => {
const id = Number.parseInt(req.params.id ?? "", 10);
if (!Number.isInteger(id) || id <= 0)
return sendError(res, 400, "Invalid folder id");
try {
const ok = await storage.deleteFolder(id);
if (!ok) return sendError(res, 404, "Folder not found or delete failed");
return res.json({ error: false, data: { id } });
} catch (err) {
return sendError(res, 500, "Failed to delete folder");
}
}
);
/* ---------- Files inside folder (pagination) ----------
GET /folders/:id/files?limit=&offset=
id = "null" lists files with folderId = null
responses serialized
*/
router.get(
"/folders/:id/files",
async (req: Request, res: Response): Promise<any> => {
const rawId = req.params.id;
const folderId = rawId === "null" ? null : Number.parseInt(rawId ?? "", 10);
if (folderId !== null && (!Number.isInteger(folderId) || folderId <= 0)) {
return sendError(res, 400, "Invalid folder id");
}
const limit = parsePositiveInt(req.query.limit, 50);
const offset = parsePositiveInt(req.query.offset, 0);
try {
const files = await storage.listFilesInFolder(folderId, limit, offset);
const totalCount = await storage.countFilesInFolder(folderId);
const serialized = files.map(serializeFile);
return res.json({ error: false, data: serialized, totalCount });
} catch (err) {
return sendError(res, 500, "Failed to list files for folder");
}
}
);
/* ---------- File CRUD (init, update metadata, delete) ----------
POST /folders/:id/files { userId, name, mimeType?, expectedSize?, totalChunks? }
PUT /files/:id { name?, mimeType?, folderId? }
DELETE /files/:id
*/
const MAX_FILE_MB = 20;
const MAX_FILE_BYTES = MAX_FILE_MB * 1024 * 1024;
router.post(
"/folders/:id/files",
async (req: Request, res: Response): Promise<any> => {
const rawId = req.params.id;
const folderId = rawId === "null" ? null : Number.parseInt(rawId ?? "", 10);
if (folderId !== null && (!Number.isInteger(folderId) || folderId <= 0)) {
return sendError(res, 400, "Invalid folder id");
}
const { userId, name, mimeType } = req.body;
if (!userId || typeof name !== "string" || !name.trim()) {
return sendError(res, 400, "Missing or invalid userId/name");
}
// coerce size & chunks
let expectedSize: bigint | null = null;
if (req.body.expectedSize != null) {
try {
// coerce to BigInt safely
const asNum = Number(req.body.expectedSize);
if (!Number.isFinite(asNum) || asNum < 0) {
return sendError(res, 400, "Invalid expectedSize");
}
if (asNum > MAX_FILE_BYTES) {
// Payload Too Large
return sendError(
res,
413,
`File too large. Max allowed is ${MAX_FILE_MB} MB`
);
}
expectedSize = BigInt(String(req.body.expectedSize));
} catch {
return sendError(res, 400, "Invalid expectedSize");
}
}
let totalChunks: number | null = null;
if (req.body.totalChunks != null) {
const tc = Number(req.body.totalChunks);
if (!Number.isFinite(tc) || tc <= 0)
return sendError(res, 400, "Invalid totalChunks");
totalChunks = Math.floor(tc);
}
try {
const created = await storage.initializeFileUpload(
userId,
name.trim(),
mimeType ?? null,
expectedSize,
totalChunks,
folderId
);
return res
.status(201)
.json({ error: false, data: serializeFile(created as any) });
} catch {
return sendError(res, 500, "Failed to create file");
}
}
);
/* ---------- 2. CHUNKS (raw upload) ---------- */
router.post(
"/files/:id/chunks",
// only here: use express.raw so req.body is Buffer
express.raw({ type: () => true, limit: "100mb" }),
async (req: Request, res: Response): Promise<any> => {
const id = Number.parseInt(req.params.id ?? "", 10);
const seq = Number.parseInt(
String(req.query.seq ?? req.body.seq ?? ""),
10
);
if (!Number.isInteger(id) || id <= 0)
return sendError(res, 400, "Invalid file id");
if (!Number.isInteger(seq) || seq < 0)
return sendError(res, 400, "Invalid seq");
const body = req.body as Buffer;
if (!body || !(body instanceof Buffer)) {
return sendError(res, 400, "Expected raw binary body (Buffer)");
}
// strict size guard: any single chunk must not exceed MAX_FILE_BYTES
if (body.length > MAX_FILE_BYTES) {
return sendError(res, 413, `Chunk size exceeds ${MAX_FILE_MB} MB limit`);
}
try {
await storage.appendFileChunk(id, seq, body);
return res.json({ error: false, data: { fileId: id, seq } });
} catch (err: any) {
return sendError(res, 500, "Failed to add chunk");
}
}
);
/* ---------- 3. COMPLETE ---------- */
router.post(
"/files/:id/complete",
async (req: Request, res: Response): Promise<any> => {
const id = Number.parseInt(req.params.id ?? "", 10);
if (!Number.isInteger(id) || id <= 0)
return sendError(res, 400, "Invalid file id");
try {
// Ask storage for the file (includes chunks in your implementation)
const file = await storage.getFile(id);
if (!file) return sendError(res, 404, "File not found");
// Sum chunks' sizes (storage.getFile returns chunks ordered by seq in your impl)
const chunks = (file as any).chunks ?? [];
if (!chunks.length) return sendError(res, 400, "No chunks uploaded");
let total = 0;
for (const c of chunks) {
// c.data is Bytes / Buffer-like
total += c.data.length;
// early bailout
if (total > MAX_FILE_BYTES) {
return sendError(
res,
413,
`Assembled file is too large (${Math.round(total / 1024 / 1024)} MB). Max allowed is ${MAX_FILE_MB} MB.`
);
}
}
const result = await storage.finalizeFileUpload(id);
return res.json({ error: false, data: result });
} catch (err: any) {
return sendError(res, 500, err?.message || "Failed to complete file");
}
}
);
router.put("/files/:id", async (req: Request, res: Response): Promise<any> => {
const id = Number.parseInt(req.params.id ?? "", 10);
if (!Number.isInteger(id) || id <= 0)
return sendError(res, 400, "Invalid file id");
const updates: any = {};
if (typeof req.body.name === "string") updates.name = req.body.name.trim();
if (typeof req.body.mimeType === "string")
updates.mimeType = req.body.mimeType;
if (req.body.folderId !== undefined) updates.folderId = req.body.folderId;
try {
const updated = await storage.updateFile(id, updates);
if (!updated) return sendError(res, 404, "File not found or update failed");
return res.json({ error: false, data: serializeFile(updated as any) });
} catch (err) {
return sendError(res, 500, "Failed to update file metadata");
}
});
router.delete(
"/files/:id",
async (req: Request, res: Response): Promise<any> => {
const id = Number.parseInt(req.params.id ?? "", 10);
if (!Number.isInteger(id) || id <= 0)
return sendError(res, 400, "Invalid file id");
try {
const ok = await storage.deleteFile(id);
if (!ok) return sendError(res, 404, "File not found or delete failed");
return res.json({ error: false, data: { id } });
} catch (err) {
return sendError(res, 500, "Failed to delete file");
}
}
);
/* GET /files/:id -> return serialized metadata (used by preview modal) */
router.get("/files/:id", async (req: Request, res: Response): Promise<any> => {
const id = Number.parseInt(req.params.id ?? "", 10);
if (!Number.isInteger(id) || id <= 0)
return sendError(res, 400, "Invalid file id");
try {
const file = await storage.getFile(id);
if (!file) return sendError(res, 404, "File not found");
return res.json({ error: false, data: serializeFile(file as any) });
} catch (err) {
return sendError(res, 500, "Failed to load file");
}
});
/* GET /files/:id/content -> stream file with inline disposition for preview */
router.get(
"/files/:id/content",
async (req: Request, res: Response): Promise<any> => {
const id = Number.parseInt(req.params.id ?? "", 10);
if (!Number.isInteger(id) || id <= 0)
return sendError(res, 400, "Invalid file id");
try {
const file = await storage.getFile(id);
if (!file) return sendError(res, 404, "File not found");
const filename = (file.name ?? `file-${(file as any).id}`).replace(
/["\\]/g,
""
);
if ((file as any).mimeType)
res.setHeader("Content-Type", (file as any).mimeType);
// NOTE: inline instead of attachment so browser can render (images, pdfs)
res.setHeader(
"Content-Disposition",
`inline; filename="${encodeURIComponent(filename)}"`
);
await storage.streamFileTo(res, id);
if (!res.writableEnded) res.end();
} catch (err) {
if (res.headersSent) return res.end();
return sendError(res, 500, "Failed to stream file");
}
}
);
/* GET /files/:id/download */
router.get(
"/files/:id/download",
async (req: Request, res: Response): Promise<any> => {
const id = Number.parseInt(req.params.id ?? "", 10);
if (!Number.isInteger(id) || id <= 0)
return sendError(res, 400, "Invalid file id");
try {
const file = await storage.getFile(id);
if (!file) return sendError(res, 404, "File not found");
const filename = (file.name ?? `file-${(file as any).id}`).replace(
/["\\]/g,
""
);
if ((file as any).mimeType)
res.setHeader("Content-Type", (file as any).mimeType);
res.setHeader(
"Content-Disposition",
`attachment; filename="${encodeURIComponent(filename)}"`
);
await storage.streamFileTo(res, id);
if (!res.writableEnded) res.end();
} catch (err) {
if (res.headersSent) return res.end();
return sendError(res, 500, "Failed to stream file");
}
}
);
/* ---------- Search endpoints (separate) ----------
GET /search/folders?q=&limit=&offset=
GET /search/files?q=&type=&limit=&offset=
*/
router.get(
"/search/folders",
async (req: Request, res: Response): Promise<any> => {
const q = String(req.query.q ?? "").trim();
const limit = parsePositiveInt(req.query.limit, 20);
const offset = parsePositiveInt(req.query.offset, 0);
if (!q) return sendError(res, 400, "Missing search query parameter 'q'");
try {
const parentId = null;
const { data, total } = await storage.searchFolders(
q,
limit,
offset,
parentId
);
return res.json({ error: false, data, totalCount: total });
} catch (err) {
return sendError(res, 500, "Folder search failed");
}
}
);
router.get(
"/search/files",
async (req: Request, res: Response): Promise<any> => {
const q = String(req.query.q ?? "").trim();
const type =
typeof req.query.type === "string" ? req.query.type.trim() : undefined;
const limit = parsePositiveInt(req.query.limit, 20);
const offset = parsePositiveInt(req.query.offset, 0);
if (!q && !type)
return sendError(
res,
400,
"Provide at least one of 'q' or 'type' to search files"
);
try {
const { data, total } = await storage.searchFiles(q, type, limit, offset);
const serialized = data.map(serializeFile);
return res.json({ error: false, data: serialized, totalCount: total });
} catch (err) {
return sendError(res, 500, "File search failed");
}
}
);
export default router;

View File

@@ -0,0 +1,360 @@
import { Router, Request, Response } from "express";
import { spawn } from "child_process";
import path from "path";
import os from "os";
import fs from "fs";
import { prisma } from "@repo/db/client";
import { storage } from "../storage";
import archiver from "archiver";
import { backupDatabaseToPath } from "../services/databaseBackupService";
const router = Router();
/**
* Create a database backup
*
* - Uses pg_dump in directory format for parallel dump to a tmp dir
* - Uses 'archiver' to create zip or gzipped tar stream directly to response
* - Supports explicit override via BACKUP_ARCHIVE_FORMAT env var ('zip' or 'tar')
* - Ensures cleanup of tmp dir on success/error/client disconnect
*/
// helper to remove directory (sync to keep code straightforward)
function safeRmDir(dir: string) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch (e) {
/* ignore */
}
}
router.post("/backup", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ error: "Unauthorized" });
}
const destination = await storage.getActiveBackupDestination(userId);
// create a unique tmp directory for directory-format dump
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dental_backup_")); // MUST
// Decide archive format
// BACKUP_ARCHIVE_FORMAT can be 'zip' or 'tar' (case-insensitive)
const forced = (process.env.BACKUP_ARCHIVE_FORMAT || "").toLowerCase();
const useZip =
forced === "zip"
? true
: forced === "tar"
? false
: process.platform === "win32";
const filename = useZip
? `dental_backup_${Date.now()}.zip`
: `dental_backup_${Date.now()}.tar.gz`;
// Spawn pg_dump
const pgDump = spawn(
"pg_dump",
[
"-Fd", // DIRECTORY format (required for parallel dump)
"-j",
"4", // number of parallel jobs — MUST be >0 for parallelism
"--no-acl",
"--no-owner",
"-h",
process.env.DB_HOST || "localhost",
"-U",
process.env.DB_USER || "postgres",
process.env.DB_NAME || "dental_db",
"-f",
tmpDir, // write parallely
],
{
env: {
...process.env,
PGPASSWORD: process.env.DB_PASSWORD,
},
}
);
let pgStderr = "";
pgDump.stderr.on("data", (chunk) => {
pgStderr += chunk.toString();
});
pgDump.on("error", (err) => {
safeRmDir(tmpDir);
console.error("Failed to start pg_dump:", err);
// If headers haven't been sent, respond; otherwise just end socket
if (!res.headersSent) {
return res
.status(500)
.json({ error: "Failed to run pg_dump", details: err.message });
} else {
res.destroy(err);
}
});
pgDump.on("close", async (code) => {
if (code !== 0) {
safeRmDir(tmpDir);
console.error("pg_dump failed:", pgStderr || `exit ${code}`);
if (!res.headersSent) {
return res.status(500).json({
error: "Backup failed",
details: pgStderr || `pg_dump exited with ${code}`,
});
} else {
// headers already sent — destroy response
res.destroy(new Error("pg_dump failed"));
return;
}
}
// pg_dump succeeded — stream archive directly to response using archiver
// Set headers before piping
res.setHeader(
"Content-Disposition",
`attachment; filename="${filename}"`
);
res.setHeader(
"Content-Type",
useZip ? "application/zip" : "application/gzip"
);
const archive = archiver(
useZip ? "zip" : "tar",
useZip ? {} : { gzip: true, gzipOptions: { level: 6 } }
);
let archErr: string | null = null;
archive.on("error", (err) => {
archErr = err.message;
console.error("Archiver error:", err);
// attempt to respond with error if possible
try {
if (!res.headersSent) {
res.status(500).json({
error: "Failed to create archive",
details: err.message,
});
} else {
// if streaming already started, destroy the connection
res.destroy(err);
}
} catch (e) {
// swallow
} finally {
safeRmDir(tmpDir);
}
});
// If client disconnects while streaming
res.once("close", () => {
// destroy archiver (stop processing) and cleanup tmpDir
try {
archive.destroy();
} catch (e) {}
safeRmDir(tmpDir);
});
// When streaming finishes successfully
res.once("finish", async () => {
// cleanup the tmp dir used by pg_dump
safeRmDir(tmpDir);
// update metadata (try/catch so it won't break response flow)
try {
await storage.createBackup(userId);
await storage.deleteNotificationsByType(userId, "BACKUP");
} catch (err) {
console.error("Backup saved but metadata update failed:", err);
}
});
// Pipe archive into response
archive.pipe(res);
// Add the dumped directory contents to the archive root
// `directory(source, dest)` where dest is false/'' to place contents at archive root
archive.directory(tmpDir + path.sep, false);
// finalize archive (this starts streaming)
try {
await archive.finalize();
} catch (err: any) {
console.error("Failed to finalize archive:", err);
// if headers not sent, send 500; otherwise destroy
try {
if (!res.headersSent) {
res.status(500).json({
error: "Failed to finalize archive",
details: String(err),
});
} else {
res.destroy(err);
}
} catch (e) {}
safeRmDir(tmpDir);
}
});
} catch (err: any) {
console.error("Unexpected error in /backup:", err);
if (!res.headersSent) {
return res
.status(500)
.json({ message: "Internal server error", details: String(err) });
} else {
res.destroy(err);
}
}
});
/**
* Get database status (connected, size, records count)
*/
router.get("/status", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ error: "Unauthorized" });
}
const size = await prisma.$queryRaw<{ size: string }[]>`
SELECT pg_size_pretty(pg_database_size(current_database())) as size
`;
const patientsCount = await storage.getTotalPatientCount();
const lastBackup = await storage.getLastBackup(userId);
res.json({
connected: true,
size: size[0]?.size,
patients: patientsCount,
lastBackup: lastBackup?.createdAt ?? null,
});
} catch (err) {
console.error("Status error:", err);
res.status(500).json({
connected: false,
error: "Could not fetch database status",
});
}
});
// ==============================
// Backup Destination CRUD
// ==============================
// CREATE / UPDATE destination
router.post("/destination", async (req, res) => {
const userId = req.user?.id;
const { path: destinationPath } = req.body;
if (!userId) return res.status(401).json({ error: "Unauthorized" });
if (!destinationPath)
return res.status(400).json({ error: "Path is required" });
// validate path exists
if (!fs.existsSync(destinationPath)) {
return res.status(400).json({
error: "Backup path does not exist or drive not connected",
});
}
try {
const destination = await storage.createBackupDestination(
userId,
destinationPath
);
res.json(destination);
} catch (err) {
console.error(err);
res.status(500).json({ error: "Failed to save backup destination" });
}
});
// GET all destinations
router.get("/destination", async (req, res) => {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: "Unauthorized" });
const destinations = await storage.getAllBackupDestination(userId);
res.json(destinations);
});
// UPDATE destination
router.put("/destination/:id", async (req, res) => {
const userId = req.user?.id;
const id = Number(req.params.id);
const { path: destinationPath } = req.body;
if (!userId) return res.status(401).json({ error: "Unauthorized" });
if (!destinationPath)
return res.status(400).json({ error: "Path is required" });
if (!fs.existsSync(destinationPath)) {
return res.status(400).json({ error: "Path does not exist" });
}
const updated = await storage.updateBackupDestination(
id,
userId,
destinationPath
);
res.json(updated);
});
// DELETE destination
router.delete("/destination/:id", async (req, res) => {
const userId = req.user?.id;
const id = Number(req.params.id);
if (!userId) return res.status(401).json({ error: "Unauthorized" });
await storage.deleteBackupDestination(id, userId);
res.json({ success: true });
});
router.post("/backup-path", async (req, res) => {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: "Unauthorized" });
const destination = await storage.getActiveBackupDestination(userId);
if (!destination) {
return res.status(400).json({
error: "No backup destination configured",
});
}
if (!fs.existsSync(destination.path)) {
return res.status(400).json({
error:
"Backup destination not found. External drive may be disconnected.",
});
}
const filename = `dental_backup_${Date.now()}.zip`;
try {
await backupDatabaseToPath({
destinationPath: destination.path,
filename,
});
await storage.createBackup(userId);
await storage.deleteNotificationsByType(userId, "BACKUP");
res.json({ success: true, filename });
} catch (err: any) {
console.error(err);
res.status(500).json({
error: "Backup to destination failed",
details: err.message,
});
}
});
export default router;

View File

@@ -0,0 +1,423 @@
import { Router } from "express";
import { Request, Response } from "express";
import { storage } from "../storage";
import multer from "multer";
import { PdfFile } from "../../../../packages/db/types/pdf-types";
const upload = multer({ storage: multer.memoryStorage() });
const router = Router();
// ----------- PDF GROUPS ------------------
router.post(
"/pdf-groups",
async (req: Request, res: Response): Promise<any> => {
try {
const { patientId, groupTitle, groupTitleKey } = req.body;
if (!patientId || !groupTitle || groupTitleKey) {
return res
.status(400)
.json({ error: "Missing title, titleKey, or patientId" });
}
const group = await storage.createPdfGroup(
parseInt(patientId),
groupTitle,
groupTitleKey
);
res.json(group);
} catch (err) {
console.error("Error creating PDF group:", err);
res.status(500).json({ error: "Internal server error" });
}
}
);
router.get(
"/pdf-groups/patient/:patientId",
async (req: Request, res: Response): Promise<any> => {
try {
const { patientId } = req.params;
if (!patientId) {
return res.status(400).json({ error: "Missing patient ID" });
}
const groups = await storage.getPdfGroupsByPatientId(parseInt(patientId));
res.json(groups);
} catch (err) {
console.error("Error fetching groups by patient ID:", err);
res.status(500).json({ error: "Internal server error" });
}
}
);
router.get(
"/pdf-groups/:id",
async (req: Request, res: Response): Promise<any> => {
try {
const idParam = req.params.id;
if (!idParam) {
return res.status(400).json({ error: "Missing ID" });
}
const id = parseInt(idParam);
const group = await storage.getPdfGroupById(id);
if (!group) return res.status(404).json({ error: "Group not found" });
res.json(group);
} catch (err) {
console.error("Error fetching PDF group:", err);
res.status(500).json({ error: "Internal server error" });
}
}
);
router.get("/pdf-groups", async (req: Request, res: Response): Promise<any> => {
try {
const groups = await storage.getAllPdfGroups();
res.json(groups);
} catch (err) {
console.error("Error listing PDF groups:", err);
res.status(500).json({ error: "Internal server error" });
}
});
router.put(
"/pdf-groups/:id",
async (req: Request, res: Response): Promise<any> => {
try {
const idParam = req.params.id;
if (!idParam) {
return res.status(400).json({ error: "Missing ID" });
}
const id = parseInt(idParam);
const { title, titleKey } = req.body;
const updates: any = {};
updates.title = title;
updates.titleKey = titleKey;
const updated = await storage.updatePdfGroup(id, updates);
if (!updated) return res.status(404).json({ error: "Group not found" });
res.json(updated);
} catch (err) {
console.error("Error updating PDF group:", err);
res.status(500).json({ error: "Internal server error" });
}
}
);
router.delete(
"/pdf-groups/:id",
async (req: Request, res: Response): Promise<any> => {
try {
const idParam = req.params.id;
if (!idParam) {
return res.status(400).json({ error: "Missing ID" });
}
const id = parseInt(idParam);
const success = await storage.deletePdfGroup(id);
res.json({ success });
} catch (err) {
console.error("Error deleting PDF group:", err);
res.status(500).json({ error: "Internal server error" });
}
}
);
// ----------- PDF FILES ------------------
router.post(
"/pdf-files",
upload.single("file"),
async (req: Request, res: Response): Promise<any> => {
try {
const { groupId } = req.body;
const file = req.file;
if (!groupId || !file) {
return res.status(400).json({ error: "Missing groupId or file" });
}
const pdf = await storage.createPdfFile(
parseInt(groupId),
file.originalname,
file.buffer
);
res.json(pdf);
} catch (err) {
console.error("Error uploading PDF file:", err);
res.status(500).json({ error: "Internal server error" });
}
}
);
router.get(
"/pdf-files/group/:groupId",
async (req: Request, res: Response): Promise<any> => {
try {
const idParam = req.params.groupId;
if (!idParam) {
return res.status(400).json({ error: "Missing Groupt ID" });
}
const groupId = parseInt(idParam);
const files = await storage.getPdfFilesByGroupId(groupId);
res.json(files);
} catch (err) {
res.status(500).json({ error: "Internal server error" });
}
}
);
/**
* GET /pdf-files/group/:groupId
* Query params:
* - limit (optional, defaults to 5): number of items per page (max 1000)
* - offset (optional, defaults to 0): offset for pagination
*
* Response: { total: number, data: PdfFile[] }
*/
router.get(
"/recent-pdf-files/group/:groupId",
async (req: Request, res: Response): Promise<any> => {
try {
const rawGroupId = req.params.groupId;
if (!rawGroupId) {
return res.status(400).json({ error: "Missing groupId param" });
}
const groupId = Number(rawGroupId);
if (Number.isNaN(groupId) || groupId <= 0) {
return res.status(400).json({ error: "Invalid groupId" });
}
// Parse & sanitize query params
const limitQuery = req.query.limit;
const offsetQuery = req.query.offset;
const limit =
limitQuery !== undefined
? Math.min(Math.max(Number(limitQuery), 1), 1000) // 1..1000
: undefined; // if undefined -> treat as "no pagination" (return all)
const offset =
offsetQuery !== undefined ? Math.max(Number(offsetQuery), 0) : 0;
// Decide whether client asked for paginated response
const wantsPagination = typeof limit === "number";
if (wantsPagination) {
// storage.getPdfFilesByGroupId with pagination should return { total, data }
const result = await storage.getPdfFilesByGroupId(groupId, {
limit,
offset,
withGroup: false, // do not include group relation in listing
});
// result should be { total, data }, but handle unexpected shapes defensively
if (Array.isArray(result)) {
// fallback: storage returned full array; compute total
return res.json({ total: result.length, data: result });
}
return res.json(result);
} else {
// no limit requested -> return all files for the group
const all = (await storage.getPdfFilesByGroupId(groupId)) as PdfFile[];
return res.json({ total: all.length, data: all });
}
} catch (err) {
console.error("GET /pdf-files/group/:groupId error:", err);
return res.status(500).json({ error: "Internal server error" });
}
}
);
router.get(
"/pdf-files/:id",
async (req: Request, res: Response): Promise<any> => {
try {
const idParam = req.params.id;
if (!idParam) {
return res.status(400).json({ error: "Missing ID" });
}
const id = parseInt(idParam, 10);
if (Number.isNaN(id)) {
return res.status(400).json({ error: "Invalid ID" });
}
const pdf = await storage.getPdfFileById(id);
if (!pdf || !pdf.pdfData) {
return res.status(404).json({ error: "PDF not found" });
}
const data: any = pdf.pdfData;
// Helper: try many plausible conversions into a Buffer
function normalizeToBuffer(d: any): Buffer | null {
// Already a Buffer
if (Buffer.isBuffer(d)) return d;
// Uint8Array or other typed arrays
if (d instanceof Uint8Array) return Buffer.from(d);
// ArrayBuffer
if (d instanceof ArrayBuffer) return Buffer.from(new Uint8Array(d));
// number[] (common)
if (Array.isArray(d) && d.every((n) => typeof n === "number")) {
return Buffer.from(d as number[]);
}
// Some drivers: { data: number[] }
if (
d &&
typeof d === "object" &&
Array.isArray(d.data) &&
d.data.every((n: any) => typeof n === "number")
) {
return Buffer.from(d.data as number[]);
}
// Some drivers return object with numeric keys: { '0': 37, '1': 80, ... }
if (d && typeof d === "object") {
const keys = Object.keys(d);
const numericKeys = keys.filter((k) => /^\d+$/.test(k));
if (numericKeys.length > 0 && numericKeys.length === keys.length) {
// sort numeric keys to correct order and map to numbers
const sorted = numericKeys
.map((k) => parseInt(k, 10))
.sort((a, b) => a - b)
.map((n) => d[String(n)]);
if (sorted.every((v) => typeof v === "number")) {
return Buffer.from(sorted as number[]);
}
}
}
// Last resort: if Object.values(d) yields numbers (this is what you used originally)
try {
const vals = Object.values(d);
if (Array.isArray(vals) && vals.every((v) => typeof v === "number")) {
// coerce to number[] for TS safety
return Buffer.from(vals as number[]);
}
} catch {
// ignore
}
// give up
return null;
}
const pdfBuffer = normalizeToBuffer(data);
if (!pdfBuffer) {
console.error("Unsupported pdf.pdfData shape:", {
typeofData: typeof data,
constructorName:
data && data.constructor ? data.constructor.name : undefined,
keys:
data && typeof data === "object"
? Object.keys(data).slice(0, 20)
: undefined,
sample: (() => {
if (Array.isArray(data)) return data.slice(0, 20);
if (data && typeof data === "object") {
const vals = Object.values(data);
return Array.isArray(vals) ? vals.slice(0, 20) : undefined;
}
return String(data).slice(0, 200);
})(),
});
// Try a safe textual fallback (may produce invalid PDF but avoids crashing)
try {
const fallback = Buffer.from(String(data));
res.setHeader("Content-Type", "application/pdf");
res.setHeader(
"Content-Disposition",
`attachment; filename="${pdf.filename}"; filename*=UTF-8''${encodeURIComponent(pdf.filename)}`
);
return res.send(fallback);
} catch (err) {
console.error("Failed fallback conversion:", err);
return res.status(500).json({ error: "Cannot process PDF data" });
}
}
res.setHeader("Content-Type", "application/pdf");
res.setHeader(
"Content-Disposition",
`attachment; filename="${pdf.filename}"; filename*=UTF-8''${encodeURIComponent(pdf.filename)}`
);
res.send(pdfBuffer);
} catch (err) {
console.error("Error downloading PDF file:", err);
res.status(500).json({ error: "Internal server error" });
}
}
);
router.get(
"/pdf-files/recent",
async (req: Request, res: Response): Promise<any> => {
try {
const limit = parseInt(req.query.limit as string) || 5;
const offset = parseInt(req.query.offset as string) || 0;
const files = await storage.getRecentPdfFiles(limit, offset);
res.json(files);
} catch (err) {
console.error("Error getting recent PDF files:", err);
res.status(500).json({ error: "Internal server error" });
}
}
);
router.put(
"/pdf-files/:id",
upload.single("file"),
async (req: Request, res: Response): Promise<any> => {
try {
const idParam = req.params.id;
if (!idParam) {
return res.status(400).json({ error: "Missing ID" });
}
const id = parseInt(idParam);
const file = req.file;
const updated = await storage.updatePdfFile(id, {
filename: file?.originalname,
pdfData: file?.buffer,
});
if (!updated)
return res
.status(404)
.json({ error: "PDF not found or update failed" });
res.json(updated);
} catch (err) {
console.error("Error updating PDF file:", err);
res.status(500).json({ error: "Internal server error" });
}
}
);
router.delete(
"/pdf-files/:id",
async (req: Request, res: Response): Promise<any> => {
try {
const idParam = req.params.id;
if (!idParam) {
return res.status(400).json({ error: "Missing ID" });
}
const id = parseInt(idParam);
const success = await storage.deletePdfFile(id);
res.json({ success });
} catch (err) {
console.error("Error deleting PDF file:", err);
res.status(500).json({ error: "Internal server error" });
}
}
);
export default router;

View File

@@ -0,0 +1,99 @@
import { Router } from "express";
import type { Request, Response } from "express";
import { storage } from "../storage";
const router = Router();
/**
* GET /api/reports/export
* query:
* - type = patients_with_balance | collections_by_doctor
* - from, to = optional ISO date strings (YYYY-MM-DD)
* - staffId = required for collections_by_doctor
* - format = csv (we expect csv; if missing default to csv)
*/
function escapeCsvCell(v: any) {
if (v === null || v === undefined) return "";
const s = String(v).replace(/\r?\n/g, " ");
if (s.includes('"') || s.includes(",") || s.includes("\n")) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}
router.get("/export", async (req: Request, res: Response): Promise<any> => {
try {
const type = String(req.query.type || "");
const from = req.query.from ? new Date(String(req.query.from)) : undefined;
const to = req.query.to ? new Date(String(req.query.to)) : undefined;
const staffId = req.query.staffId ? Number(req.query.staffId) : undefined;
const format = String(req.query.format || "csv").toLowerCase();
if (format !== "csv") {
return res.status(400).json({ message: "Only CSV export is supported" });
}
let patientsSummary: any[] = [];
if (type === "patients_with_balance") {
patientsSummary = await storage.fetchAllPatientsWithBalances(from, to);
} else if (type === "collections_by_doctor") {
if (!staffId || !Number.isFinite(staffId) || staffId <= 0) {
return res.status(400).json({ message: "Missing or invalid staffId for collections_by_doctor" });
}
patientsSummary = await storage.fetchAllPatientsForDoctor(staffId, from, to);
} else {
return res.status(400).json({ message: "Unsupported report type" });
}
const patientsWithFinancials = await storage.buildExportRowsForPatients(patientsSummary, 5000);
// Build CSV - flattened rows
// columns: patientId, patientName, currentBalance, type, date, procedureCode, billed, paid, adjusted, totalDue, status
const header = [
"patientId",
"patientName",
"currentBalance",
"type",
"date",
"procedureCode",
"billed",
"paid",
"adjusted",
"totalDue",
"status",
];
const lines = [header.join(",")];
for (const p of patientsWithFinancials) {
const name = `${p.firstName ?? ""} ${p.lastName ?? ""}`.trim();
for (const fr of p.financialRows) {
lines.push(
[
escapeCsvCell(p.patientId),
escapeCsvCell(name),
(Number(p.currentBalance ?? 0)).toFixed(2),
escapeCsvCell(fr.type),
escapeCsvCell(fr.date),
escapeCsvCell(fr.procedureCode),
(Number(fr.billed ?? 0)).toFixed(2),
(Number(fr.paid ?? 0)).toFixed(2),
(Number(fr.adjusted ?? 0)).toFixed(2),
(Number(fr.totalDue ?? 0)).toFixed(2),
escapeCsvCell(fr.status),
].join(",")
);
}
}
const fname = `report-${type}-${new Date().toISOString().slice(0, 10)}.csv`;
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="${fname}"`);
return res.send(lines.join("\n"));
} catch (err: any) {
console.error("[/api/reports/export] error:", err?.message ?? err, err?.stack);
return res.status(500).json({ message: "Export error" });
}
});
export default router;

View File

@@ -0,0 +1,46 @@
import { Router } from "express";
import patientsRoutes from "./patients";
import appointmentsRoutes from "./appointments";
import appointmentProceduresRoutes from "./appointments-procedures";
import usersRoutes from "./users";
import staffsRoutes from "./staffs";
import npiProvidersRoutes from "./npiProviders";
import claimsRoutes from "./claims";
import patientDataExtractionRoutes from "./patientDataExtraction";
import insuranceCredsRoutes from "./insuranceCreds";
import documentsRoutes from "./documents";
import patientDocumentsRoutes from "./patient-documents";
import insuranceStatusRoutes from "./insuranceStatus";
import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA";
import paymentsRoutes from "./payments";
import databaseManagementRoutes from "./database-management";
import notificationsRoutes from "./notifications";
import paymentOcrRoutes from "./paymentOcrExtraction";
import cloudStorageRoutes from "./cloud-storage";
import paymentsReportsRoutes from "./payments-reports";
import exportPaymentsReportsRoutes from "./export-payments-reports";
const router = Router();
router.use("/patients", patientsRoutes);
router.use("/appointments", appointmentsRoutes);
router.use("/appointment-procedures", appointmentProceduresRoutes);
router.use("/users", usersRoutes);
router.use("/staffs", staffsRoutes);
router.use("/npiProviders", npiProvidersRoutes);
router.use("/patientDataExtraction", patientDataExtractionRoutes);
router.use("/claims", claimsRoutes);
router.use("/insuranceCreds", insuranceCredsRoutes);
router.use("/documents", documentsRoutes);
router.use("/patient-documents", patientDocumentsRoutes);
router.use("/insurance-status", insuranceStatusRoutes);
router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes);
router.use("/payments", paymentsRoutes);
router.use("/database-management", databaseManagementRoutes);
router.use("/notifications", notificationsRoutes);
router.use("/payment-ocr", paymentOcrRoutes);
router.use("/cloud-storage", cloudStorageRoutes);
router.use("/payments-reports", paymentsReportsRoutes);
router.use("/export-payments-reports", exportPaymentsReportsRoutes);
export default router;

View File

@@ -0,0 +1,126 @@
import express, { Request, Response } from "express";
import { storage } from "../storage";
import { z } from "zod";
import {
insertInsuranceCredentialSchema,
InsuranceCredential,
} from "@repo/db/types";
const router = express.Router();
// ✅ Get all credentials for a user
router.get("/", async (req: Request, res: Response): Promise<any> => {
try {
if (!req.user || !req.user.id) {
return res
.status(401)
.json({ message: "Unauthorized: user info missing" });
}
const userId = req.user.id;
const credentials = await storage.getInsuranceCredentialsByUser(userId);
return res.status(200).json(credentials);
} catch (err) {
return res
.status(500)
.json({ error: "Failed to fetch credentials", details: String(err) });
}
});
// ✅ Create credential for a user
router.post("/", async (req: Request, res: Response): Promise<any> => {
try {
if (!req.user || !req.user.id) {
return res
.status(401)
.json({ message: "Unauthorized: user info missing" });
}
const userId = req.user.id;
const parseResult = insertInsuranceCredentialSchema.safeParse({
...req.body,
userId,
});
if (!parseResult.success) {
const flat = (
parseResult as typeof parseResult & { error: z.ZodError<any> }
).error.flatten();
const firstError =
Object.values(flat.fieldErrors)[0]?.[0] || "Invalid input";
return res.status(400).json({
message: firstError,
details: flat.fieldErrors,
});
}
const credential = await storage.createInsuranceCredential(
parseResult.data
);
return res.status(201).json(credential);
} catch (err: any) {
if (err.code === "P2002") {
return res.status(400).json({
message: `Credential with this ${err.meta?.target?.join(", ")} already exists.`,
});
}
return res
.status(500)
.json({ error: "Failed to create credential", details: String(err) });
}
});
// ✅ Update credential
router.put("/:id", async (req: Request, res: Response): Promise<any> => {
try {
const id = Number(req.params.id);
if (isNaN(id)) return res.status(400).send("Invalid credential ID");
const updates = req.body as Partial<InsuranceCredential>;
const credential = await storage.updateInsuranceCredential(id, updates);
return res.status(200).json(credential);
} catch (err) {
return res
.status(500)
.json({ error: "Failed to update credential", details: String(err) });
}
});
// ✅ Delete a credential
router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const id = Number(req.params.id);
if (isNaN(id)) return res.status(400).send("Invalid ID");
// 1) Check existence
const existing = await storage.getInsuranceCredential(id);
if (!existing)
return res.status(404).json({ message: "Credential not found" });
// 2) Ownership check
if (existing.userId !== userId) {
return res.status(403).json({
message:
"Forbidden: Credentials belongs to a different user, you can't delete this.",
});
}
// 3) Delete (storage method enforces userId + id)
const ok = await storage.deleteInsuranceCredential(userId, id);
if (!ok) {
return res
.status(404)
.json({ message: "Credential not found or already deleted" });
}
return res.status(204).send();
} catch (err) {
return res
.status(500)
.json({ error: "Failed to delete credential", details: String(err) });
}
});
export default router;

View File

@@ -0,0 +1,844 @@
import { Router } from "express";
import { Request, Response } from "express";
import { storage } from "../storage";
import { forwardToSeleniumInsuranceEligibilityAgent } from "../services/seleniumInsuranceEligibilityClient";
import fs from "fs/promises";
import path from "path";
import PDFDocument from "pdfkit";
import { forwardToSeleniumInsuranceClaimStatusAgent } from "../services/seleniumInsuranceClaimStatusClient";
import fsSync from "fs";
import { emptyFolderContainingFile } from "../utils/emptyTempFolder";
import forwardToPatientDataExtractorService from "../services/patientDataExtractorService";
import {
InsertPatient,
insertPatientSchema,
} from "../../../../packages/db/types/patient-types";
import { formatDobForAgent } from "../utils/dateUtils";
const router = Router();
/** Utility: naive name splitter */
function splitName(fullName?: string | null) {
if (!fullName) return { firstName: "", lastName: "" };
const parts = fullName.trim().split(/\s+/).filter(Boolean);
const firstName = parts.shift() ?? "";
const lastName = parts.join(" ") ?? "";
return { firstName, lastName };
}
/**
* Ensure patient exists for given insuranceId.
* If exists -> update first/last name when different.
* If not -> create using provided fields.
* Returns the patient object (the version read from DB after potential create/update).
*/
async function createOrUpdatePatientByInsuranceId(options: {
insuranceId: string;
firstName?: string | null;
lastName?: string | null;
dob?: string | Date | null;
userId: number;
}) {
const { insuranceId, firstName, lastName, dob, userId } = options;
if (!insuranceId) throw new Error("Missing insuranceId");
let patient = await storage.getPatientByInsuranceId(insuranceId);
// Normalize incoming names
const incomingFirst = firstName?.trim() ?? "";
const incomingLast = lastName?.trim() ?? "";
if (patient && patient.id) {
// update only if different
const updates: any = {};
if (
incomingFirst &&
String(patient.firstName ?? "").trim() !== incomingFirst
) {
updates.firstName = incomingFirst;
}
if (
incomingLast &&
String(patient.lastName ?? "").trim() !== incomingLast
) {
updates.lastName = incomingLast;
}
if (Object.keys(updates).length > 0) {
await storage.updatePatient(patient.id, updates);
// Refetch to get updated data
patient = await storage.getPatientByInsuranceId(insuranceId);
}
return patient;
} else {
// inside createOrUpdatePatientByInsuranceId, when creating:
const createPayload: any = {
firstName: incomingFirst,
lastName: incomingLast,
dateOfBirth: dob, // raw from caller (string | Date | null)
gender: "",
phone: "",
userId,
insuranceId,
};
let patientData: InsertPatient;
try {
patientData = insertPatientSchema.parse(createPayload);
} catch (err) {
// handle malformed dob or other validation errors conservatively
console.warn(
"Failed to validate patient payload in insurance flow:",
err
);
// either rethrow or drop invalid fields — here we drop dob and proceed
const safePayload = { ...createPayload };
delete (safePayload as any).dateOfBirth;
patientData = insertPatientSchema.parse(safePayload);
}
await storage.createPatient(patientData);
// Return the created patient
return await storage.getPatientByInsuranceId(insuranceId);
}
}
/**
* /eligibility-check
* - run selenium
* - if pdf created -> call extractor -> get name
* - create or update patient (by memberId)
* - attach PDF to patient (create pdf group/file)
* - return { patient, pdfFileId, extractedName ... }
*/
router.post(
"/eligibility-check",
async (req: Request, res: Response): Promise<any> => {
if (!req.body.data) {
return res
.status(400)
.json({ error: "Missing Insurance Eligibility data for selenium" });
}
if (!req.user || !req.user.id) {
return res.status(401).json({ error: "Unauthorized: user info missing" });
}
let seleniumResult: any = undefined;
let createdPdfFileId: number | null = null;
let outputResult: any = {};
const extracted: any = {};
try {
// const insuranceEligibilityData = JSON.parse(req.body.data);
// Handle both string and object data
const insuranceEligibilityData = typeof req.body.data === 'string'
? JSON.parse(req.body.data)
: req.body.data;
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
req.user.id,
insuranceEligibilityData.insuranceSiteKey
);
if (!credentials) {
return res.status(404).json({
error:
"No insurance credentials found for this provider, Kindly Update this at Settings Page.",
});
}
const enrichedData = {
...insuranceEligibilityData,
massdhpUsername: credentials.username,
massdhpPassword: credentials.password,
};
// 1) Run selenium agent
try {
seleniumResult =
await forwardToSeleniumInsuranceEligibilityAgent(enrichedData);
} catch (seleniumErr: any) {
return res.status(502).json({
error: "Selenium service failed",
detail: seleniumErr?.message ?? String(seleniumErr),
});
}
// 2) Extract data from selenium result (page extraction) and PDF
let extracted: any = {};
// First, try to get data from selenium's page extraction
if (seleniumResult.firstName || seleniumResult.lastName) {
extracted.firstName = seleniumResult.firstName || null;
extracted.lastName = seleniumResult.lastName || null;
console.log('[eligibility-check] Using name from selenium extraction:', {
firstName: extracted.firstName,
lastName: extracted.lastName
});
}
// Also check for combined name field (fallback)
else if (seleniumResult.name) {
const parts = splitName(seleniumResult.name);
extracted.firstName = parts.firstName;
extracted.lastName = parts.lastName;
console.log('[eligibility-check] Using combined name from selenium extraction:', parts);
}
// If no name from selenium, try PDF extraction
if (!extracted.firstName && !extracted.lastName &&
seleniumResult?.pdf_path &&
seleniumResult.pdf_path.endsWith(".pdf")
) {
try {
const pdfPath = seleniumResult.pdf_path;
console.log('[eligibility-check] Extracting data from PDF:', pdfPath);
const pdfBuffer = await fs.readFile(pdfPath);
const extraction = await forwardToPatientDataExtractorService({
buffer: pdfBuffer,
originalname: path.basename(pdfPath),
mimetype: "application/pdf",
} as any);
console.log('[eligibility-check] PDF Extraction result:', extraction);
if (extraction.name) {
const parts = splitName(extraction.name);
extracted.firstName = parts.firstName;
extracted.lastName = parts.lastName;
console.log('[eligibility-check] Split name from PDF:', parts);
} else {
console.warn('[eligibility-check] No name extracted from PDF');
}
} catch (extractErr: any) {
console.error('[eligibility-check] Patient data extraction failed:', extractErr);
// Continue without extracted names - we'll use form names or create patient with empty names
}
}
// Step-3) Create or update patient name using extracted info (prefer extractor -> request)
const insuranceId = String(
insuranceEligibilityData.memberId ?? ""
).trim();
if (!insuranceId) {
return res.status(400).json({ error: "Missing memberId" });
}
// Always prioritize extracted data from MassHealth over form input
// Form input is only used as fallback when extraction fails
const preferFirst = extracted.firstName || null;
const preferLast = extracted.lastName || null;
console.log('[eligibility-check] Name priority:', {
extracted: { firstName: extracted.firstName, lastName: extracted.lastName },
fromForm: { firstName: insuranceEligibilityData.firstName, lastName: insuranceEligibilityData.lastName },
using: { firstName: preferFirst, lastName: preferLast }
});
let patient;
try {
patient = await createOrUpdatePatientByInsuranceId({
insuranceId,
firstName: preferFirst,
lastName: preferLast,
dob: insuranceEligibilityData.dateOfBirth,
userId: req.user.id,
});
console.log('[eligibility-check] Patient after create/update:', patient);
} catch (patientOpErr: any) {
return res.status(500).json({
error: "Failed to create/update patient",
detail: patientOpErr?.message ?? String(patientOpErr),
});
}
// ✅ Step 4: Update patient status based on selenium result
if (patient && patient.id !== undefined) {
// Use eligibility from selenium extraction if available, otherwise default to UNKNOWN
let newStatus = "UNKNOWN";
if (seleniumResult.eligibility === "Y") {
newStatus = "ACTIVE";
} else if (seleniumResult.eligibility === "N") {
newStatus = "INACTIVE";
}
// Prepare updates object
const updates: any = { status: newStatus };
// Update insurance provider if extracted
if (seleniumResult.insurance) {
updates.insuranceProvider = seleniumResult.insurance;
console.log('[eligibility-check] Updating insurance provider:', seleniumResult.insurance);
}
await storage.updatePatient(patient.id, updates);
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}${seleniumResult.insurance ? ', insurance updated' : ''}`;
console.log('[eligibility-check] Status updated:', {
patientId: patient.id,
newStatus,
eligibility: seleniumResult.eligibility,
insurance: seleniumResult.insurance
});
// ✅ Step 5: Handle PDF Upload
if (
seleniumResult.pdf_path &&
seleniumResult.pdf_path.endsWith(".pdf")
) {
const pdfBuffer = await fs.readFile(seleniumResult.pdf_path);
const groupTitle = "Eligibility Status";
const groupTitleKey = "ELIGIBILITY_STATUS";
let group = await storage.findPdfGroupByPatientTitleKey(
patient.id,
groupTitleKey
);
// Step 5b: Create group if it doesnt exist
if (!group) {
group = await storage.createPdfGroup(
patient.id,
groupTitle,
groupTitleKey
);
}
if (!group?.id) {
throw new Error("PDF group creation failed: missing group ID");
}
const created = await storage.createPdfFile(
group.id,
path.basename(seleniumResult.pdf_path),
pdfBuffer
);
// created could be { id, filename } or just id, adapt to your storage API.
if (created && typeof created === "object" && "id" in created) {
createdPdfFileId = Number(created.id);
}
outputResult.pdfUploadStatus = `PDF saved to group: ${group.title}`;
} else {
outputResult.pdfUploadStatus =
"No valid PDF path provided by Selenium, Couldn't upload pdf to server.";
}
} else {
outputResult.patientUpdateStatus =
"Patient not found or missing ID; no update performed";
}
res.json({
patientUpdateStatus: outputResult.patientUpdateStatus,
pdfUploadStatus: outputResult.pdfUploadStatus,
pdfFileId: createdPdfFileId,
});
} catch (err: any) {
console.error(err);
return res.status(500).json({
error: err.message || "Failed to forward to selenium agent",
});
} finally {
try {
if (seleniumResult && seleniumResult.pdf_path) {
await emptyFolderContainingFile(seleniumResult.pdf_path);
} else {
console.log(`[eligibility-check] no pdf_path available to cleanup`);
}
} catch (cleanupErr) {
console.error(
`[eligibility-check cleanup failed for ${seleniumResult?.pdf_path}`,
cleanupErr
);
}
}
}
);
router.post(
"/claim-status-check",
async (req: Request, res: Response): Promise<any> => {
if (!req.body.data) {
return res
.status(400)
.json({ error: "Missing Insurance Status data for selenium" });
}
if (!req.user || !req.user.id) {
return res.status(401).json({ error: "Unauthorized: user info missing" });
}
let result: any = undefined;
async function imageToPdfBuffer(imagePath: string): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
try {
const doc = new PDFDocument({ autoFirstPage: false });
const chunks: Uint8Array[] = [];
// collect data chunks
doc.on("data", (chunk: any) => chunks.push(chunk));
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", (err: any) => reject(err));
const A4_WIDTH = 595.28; // points
const A4_HEIGHT = 841.89; // points
doc.addPage({ size: [A4_WIDTH, A4_HEIGHT] });
doc.image(imagePath, 0, 0, {
fit: [A4_WIDTH, A4_HEIGHT],
align: "center",
valign: "center",
});
doc.end();
} catch (err) {
reject(err);
}
});
}
try {
const insuranceClaimStatusData = JSON.parse(req.body.data);
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
req.user.id,
insuranceClaimStatusData.insuranceSiteKey
);
if (!credentials) {
return res.status(404).json({
error:
"No insurance credentials found for this provider, Kindly Update this at Settings Page.",
});
}
const enrichedData = {
...insuranceClaimStatusData,
massdhpUsername: credentials.username,
massdhpPassword: credentials.password,
};
result = await forwardToSeleniumInsuranceClaimStatusAgent(enrichedData);
let createdPdfFileId: number | null = null;
// ✅ Step 1: Check result
const patient = await storage.getPatientByInsuranceId(
insuranceClaimStatusData.memberId
);
if (patient && patient.id !== undefined) {
let pdfBuffer: Buffer | null = null;
let generatedPdfPath: string | null = null;
if (
result.ss_path &&
(result.ss_path.endsWith(".png") ||
result.ss_path.endsWith(".jpg") ||
result.ss_path.endsWith(".jpeg"))
) {
try {
// Ensure file exists
if (!fsSync.existsSync(result.ss_path)) {
throw new Error(`Screenshot file not found: ${result.ss_path}`);
}
// Convert image to PDF buffer
pdfBuffer = await imageToPdfBuffer(result.ss_path);
// Optionally write generated PDF to temp path (so name is available for createPdfFile)
const pdfFileName = `claimStatus_${insuranceClaimStatusData.memberId}_${Date.now()}.pdf`;
generatedPdfPath = path.join(
path.dirname(result.ss_path),
pdfFileName
);
await fs.writeFile(generatedPdfPath, pdfBuffer);
} catch (err) {
console.error("Failed to convert screenshot to PDF:", err);
result.pdfUploadStatus = `Failed to convert screenshot to PDF: ${String(err)}`;
}
} else {
result.pdfUploadStatus =
"No valid PDF or screenshot path provided by Selenium; nothing to upload.";
}
if (pdfBuffer && generatedPdfPath) {
const groupTitle = "Claim Status";
const groupTitleKey = "CLAIM_STATUS";
let group = await storage.findPdfGroupByPatientTitleKey(
patient.id,
groupTitleKey
);
// Create group if missing
if (!group) {
group = await storage.createPdfGroup(
patient.id,
groupTitle,
groupTitleKey
);
}
if (!group?.id) {
throw new Error("PDF group creation failed: missing group ID");
}
// Use the basename for storage
const basename = path.basename(generatedPdfPath);
const created = await storage.createPdfFile(
group.id,
basename,
pdfBuffer
);
if (created && typeof created === "object" && "id" in created) {
createdPdfFileId = Number(created.id);
}
result.pdfUploadStatus = `PDF saved to group: ${group.title}`;
}
} else {
result.patientUpdateStatus =
"Patient not found or missing ID; no update performed";
}
res.json({
pdfUploadStatus: result.pdfUploadStatus,
pdfFileId: createdPdfFileId,
});
return;
} catch (err: any) {
console.error(err);
return res.status(500).json({
error: err.message || "Failed to forward to selenium agent",
});
} finally {
try {
if (result && result.ss_path) {
await emptyFolderContainingFile(result.ss_path);
} else {
console.log(`claim-status-check] no pdf_path available to cleanup`);
}
} catch (cleanupErr) {
console.error(
`[claim-status-check cleanup failed for ${result?.ss_path}`,
cleanupErr
);
}
}
}
);
router.post(
"/appointments/check-all-eligibilities",
async (req: Request, res: Response): Promise<any> => {
// Query param: date=YYYY-MM-DD (required)
const date = String(req.query.date ?? "").trim();
if (!date) {
return res
.status(400)
.json({ error: "Missing date query param (YYYY-MM-DD)" });
}
if (!req.user || !req.user.id) {
return res.status(401).json({ error: "Unauthorized: user info missing" });
}
// Track any paths that couldn't be cleaned immediately so we can try again at the end
const remainingCleanupPaths = new Set<string>();
try {
// 1) fetch appointments for the day (reuse your storage API)
const dayAppointments = await storage.getAppointmentsByDateForUser(
date,
req.user.id
);
if (!Array.isArray(dayAppointments)) {
return res
.status(500)
.json({ error: "Failed to load appointments for date" });
}
const results: Array<any> = [];
// process sequentially so selenium agent / python semaphore isn't overwhelmed
for (const apt of dayAppointments) {
// For each appointment we keep a per-appointment seleniumResult so we can cleanup its files
let seleniumResult: any = undefined;
const resultItem: any = {
appointmentId: apt.id,
patientId: apt.patientId ?? null,
processed: false,
error: null,
pdfFileId: null,
patientUpdateStatus: null,
warning: null,
};
try {
// fetch patient record (use getPatient or getPatientById depending on your storage)
const patient = apt.patientId
? await storage.getPatient(apt.patientId)
: null;
const memberId = (patient?.insuranceId ?? "").toString().trim();
// create a readable patient label for error messages
const patientLabel = patient
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() ||
`patient#${patient.id}`
: `patient#${apt.patientId ?? "unknown"}`;
const aptLabel = `appointment#${apt.id}${apt.date ? ` (${apt.date}${apt.startTime ? ` ${apt.startTime}` : ""})` : ""}`;
if (!memberId) {
resultItem.error = `Missing insuranceId for ${patientLabel} — skipping ${aptLabel}`;
results.push(resultItem);
continue;
}
// prepare eligibility data; prefer patient DOB + name if present
const dob = patient?.dateOfBirth;
if (!dob) {
resultItem.error = `Missing dob for ${patientLabel} — skipping ${aptLabel}`;
results.push(resultItem);
continue;
}
// Convert Date object → YYYY-MM-DD string - req for selenium agent.
const dobStr = formatDobForAgent(dob);
if (!dobStr) {
resultItem.error = `Invalid or missing DOB for ${patientLabel} — skipping ${aptLabel}`;
results.push(resultItem);
continue;
}
const payload = {
memberId,
dateOfBirth: dobStr,
insuranceSiteKey: "MH",
};
// Get credentials for this user+site
const credentials =
await storage.getInsuranceCredentialByUserAndSiteKey(
req.user.id,
payload.insuranceSiteKey
);
if (!credentials) {
resultItem.error = `No insurance credentials found for siteKey — skipping ${aptLabel} for ${patientLabel}`;
results.push(resultItem);
continue;
}
// enrich payload
const enriched = {
...payload,
massdhpUsername: credentials.username,
massdhpPassword: credentials.password,
};
// forward to selenium agent (sequential)
try {
seleniumResult =
await forwardToSeleniumInsuranceEligibilityAgent(enriched);
} catch (seleniumErr: any) {
resultItem.error = `Selenium agent failed for ${patientLabel} (${aptLabel}): ${seleniumErr?.message ?? String(seleniumErr)}`;
results.push(resultItem);
continue;
}
// Attempt extraction (if pdf_path present)
const extracted: any = {};
if (
seleniumResult?.pdf_path &&
seleniumResult.pdf_path.endsWith(".pdf")
) {
try {
const pdfPath = seleniumResult.pdf_path;
const pdfBuffer = await fs.readFile(pdfPath);
const extraction = await forwardToPatientDataExtractorService({
buffer: pdfBuffer,
originalname: path.basename(pdfPath),
mimetype: "application/pdf",
} as any);
if (extraction.name) {
const parts = splitName(extraction.name);
extracted.firstName = parts.firstName;
extracted.lastName = parts.lastName;
}
} catch (extractErr: any) {
resultItem.warning = `Extraction failed: ${extractErr?.message ?? String(extractErr)}`;
}
}
// create or update patient by insuranceId — prefer extracted name
const preferFirst = extracted.firstName ?? null;
const preferLast = extracted.lastName ?? null;
try {
await createOrUpdatePatientByInsuranceId({
insuranceId: memberId,
firstName: preferFirst,
lastName: preferLast,
dob: payload.dateOfBirth,
userId: req.user.id,
});
} catch (patientOpErr: any) {
resultItem.error = `Failed to create/update patient ${patientLabel} for ${aptLabel}: ${patientOpErr?.message ?? String(patientOpErr)}`;
results.push(resultItem);
continue;
}
// fetch patient again
const updatedPatient =
await storage.getPatientByInsuranceId(memberId);
if (!updatedPatient || !updatedPatient.id) {
resultItem.error = `Patient not found after create/update for ${patientLabel} (${aptLabel})`;
results.push(resultItem);
continue;
}
// Update patient status based on seleniumResult.eligibility
const newStatus =
seleniumResult?.eligibility === "Y" ? "ACTIVE" : "INACTIVE";
// 1. updating patient
await storage.updatePatient(updatedPatient.id, { status: newStatus });
resultItem.patientUpdateStatus = `Patient status updated to ${newStatus}`;
// 2. updating appointment status - for aptmnt page
try {
await storage.updateAppointment(Number(apt.id), {
eligibilityStatus: newStatus,
});
resultItem.appointmentUpdateStatus = `Appointment eligibility set to ${newStatus}`;
} catch (apptUpdateErr: any) {
resultItem.warning =
(resultItem.warning ? resultItem.warning + " | " : "") +
`Failed to update appointment eligibility: ${apptUpdateErr?.message ?? String(apptUpdateErr)}`;
}
// If PDF exists, upload to PdfGroup (ELIGIBILITY_STATUS)
if (
seleniumResult?.pdf_path &&
seleniumResult.pdf_path.endsWith(".pdf")
) {
try {
const pdfBuf = await fs.readFile(seleniumResult.pdf_path);
const groupTitle = "Eligibility Status";
const groupTitleKey = "ELIGIBILITY_STATUS";
let group = await storage.findPdfGroupByPatientTitleKey(
updatedPatient.id,
groupTitleKey
);
if (!group) {
group = await storage.createPdfGroup(
updatedPatient.id,
groupTitle,
groupTitleKey
);
}
if (!group?.id)
throw new Error("Failed to create/find pdf group");
const created = await storage.createPdfFile(
group.id,
path.basename(seleniumResult.pdf_path),
pdfBuf
);
if (created && typeof created === "object" && "id" in created) {
resultItem.pdfFileId = Number(created.id);
} else if (typeof created === "number") {
resultItem.pdfFileId = created;
} else if (created && (created as any).id) {
resultItem.pdfFileId = (created as any).id;
}
resultItem.processed = true;
} catch (pdfErr: any) {
resultItem.warning = `PDF upload failed for ${patientLabel} (${aptLabel}): ${pdfErr?.message ?? String(pdfErr)}`;
}
} else {
// no pdf; still mark processed true (status updated)
resultItem.processed = true;
resultItem.pdfFileId = null;
}
results.push(resultItem);
} catch (err: any) {
resultItem.error = `Unexpected error for appointment#${apt.id}: ${err?.message ?? String(err)}`;
results.push(resultItem);
console.error(
"[batch eligibility] unexpected error for appointment",
apt.id,
err
);
} finally {
// Per-appointment cleanup: always try to remove selenium temp files for this appointment
try {
if (
seleniumResult &&
(seleniumResult.pdf_path || seleniumResult.ss_path)
) {
// prefer pdf_path, fallback to ss_path
const candidatePath =
seleniumResult.pdf_path ?? seleniumResult.ss_path;
try {
await emptyFolderContainingFile(candidatePath);
} catch (cleanupErr: any) {
console.warn(
`[batch cleanup] failed to clean ${candidatePath} for appointment ${apt.id}`,
cleanupErr
);
// remember path for final cleanup attempt
remainingCleanupPaths.add(candidatePath);
}
}
} catch (cleanupOuterErr: any) {
console.warn(
"[batch cleanup] unexpected error during per-appointment cleanup",
cleanupOuterErr
);
// don't throw — we want to continue processing next appointments
}
} // end try/catch/finally per appointment
} // end for appointments
// return summary
return res.json({ date, count: results.length, results });
} catch (err: any) {
console.error("[check-all-eligibilities] error", err);
return res
.status(500)
.json({ error: err?.message ?? "Internal server error" });
} finally {
// Final cleanup attempt for any remaining paths we couldn't delete earlier
try {
if (remainingCleanupPaths.size > 0) {
for (const p of remainingCleanupPaths) {
try {
await emptyFolderContainingFile(p);
} catch (finalCleanupErr: any) {
console.error(`[final cleanup] failed for ${p}`, finalCleanupErr);
}
}
}
} catch (outerFinalErr: any) {
console.error(
"[check-all-eligibilities final cleanup] unexpected error",
outerFinalErr
);
}
}
}
);
export default router;

View File

@@ -0,0 +1,699 @@
import { Router, Request, Response } from "express";
import { storage } from "../storage";
import {
forwardToSeleniumDdmaEligibilityAgent,
forwardOtpToSeleniumDdmaAgent,
getSeleniumDdmaSessionStatus,
} from "../services/seleniumDdmaInsuranceEligibilityClient";
import fs from "fs/promises";
import fsSync from "fs";
import path from "path";
import PDFDocument from "pdfkit";
import { emptyFolderContainingFile } from "../utils/emptyTempFolder";
import {
InsertPatient,
insertPatientSchema,
} from "../../../../packages/db/types/patient-types";
import { io } from "../socket";
const router = Router();
/** Job context stored in memory by sessionId */
interface DdmaJobContext {
userId: number;
insuranceEligibilityData: any; // parsed, enriched (includes username/password)
socketId?: string;
}
const ddmaJobs: Record<string, DdmaJobContext> = {};
/** Utility: naive name splitter */
function splitName(fullName?: string | null) {
if (!fullName) return { firstName: "", lastName: "" };
const parts = fullName.trim().split(/\s+/).filter(Boolean);
const firstName = parts.shift() ?? "";
const lastName = parts.join(" ") ?? "";
return { firstName, lastName };
}
async function imageToPdfBuffer(imagePath: string): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
try {
const doc = new PDFDocument({ autoFirstPage: false });
const chunks: Uint8Array[] = [];
doc.on("data", (chunk: any) => chunks.push(chunk));
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", (err: any) => reject(err));
const A4_WIDTH = 595.28; // points
const A4_HEIGHT = 841.89; // points
doc.addPage({ size: [A4_WIDTH, A4_HEIGHT] });
doc.image(imagePath, 0, 0, {
fit: [A4_WIDTH, A4_HEIGHT],
align: "center",
valign: "center",
});
doc.end();
} catch (err) {
reject(err);
}
});
}
/**
* Ensure patient exists for given insuranceId.
*/
async function createOrUpdatePatientByInsuranceId(options: {
insuranceId: string;
firstName?: string | null;
lastName?: string | null;
dob?: string | Date | null;
userId: number;
}) {
const { insuranceId, firstName, lastName, dob, userId } = options;
if (!insuranceId) throw new Error("Missing insuranceId");
const incomingFirst = (firstName || "").trim();
const incomingLast = (lastName || "").trim();
let patient = await storage.getPatientByInsuranceId(insuranceId);
if (patient && patient.id) {
const updates: any = {};
if (
incomingFirst &&
String(patient.firstName ?? "").trim() !== incomingFirst
) {
updates.firstName = incomingFirst;
}
if (
incomingLast &&
String(patient.lastName ?? "").trim() !== incomingLast
) {
updates.lastName = incomingLast;
}
if (Object.keys(updates).length > 0) {
await storage.updatePatient(patient.id, updates);
}
return;
} else {
const createPayload: any = {
firstName: incomingFirst,
lastName: incomingLast,
dateOfBirth: dob,
gender: "",
phone: "",
userId,
insuranceId,
};
let patientData: InsertPatient;
try {
patientData = insertPatientSchema.parse(createPayload);
} catch (err) {
const safePayload = { ...createPayload };
delete (safePayload as any).dateOfBirth;
patientData = insertPatientSchema.parse(safePayload);
}
await storage.createPatient(patientData);
}
}
/**
* When Selenium finishes for a given sessionId, run your patient + PDF pipeline,
* and return the final API response shape.
*/
async function handleDdmaCompletedJob(
sessionId: string,
job: DdmaJobContext,
seleniumResult: any
) {
let createdPdfFileId: number | null = null;
const outputResult: any = {};
// We'll wrap the processing in try/catch/finally so cleanup always runs
try {
// 1) ensuring memberid.
const insuranceEligibilityData = job.insuranceEligibilityData;
const insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
if (!insuranceId) {
throw new Error("Missing memberId for ddma job");
}
// 2) Create or update patient (with name from selenium result if available)
const patientNameFromResult =
typeof seleniumResult?.patientName === "string"
? seleniumResult.patientName.trim()
: null;
const { firstName, lastName } = splitName(patientNameFromResult);
await createOrUpdatePatientByInsuranceId({
insuranceId,
firstName,
lastName,
dob: insuranceEligibilityData.dateOfBirth,
userId: job.userId,
});
// 3) Update patient status + PDF upload
const patient = await storage.getPatientByInsuranceId(
insuranceEligibilityData.memberId
);
if (!patient?.id) {
outputResult.patientUpdateStatus =
"Patient not found; no update performed";
return {
patientUpdateStatus: outputResult.patientUpdateStatus,
pdfUploadStatus: "none",
pdfFileId: null,
};
}
// update patient status.
const newStatus =
seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE";
await storage.updatePatient(patient.id, { status: newStatus });
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;
// convert screenshot -> pdf if available
let pdfBuffer: Buffer | null = null;
let generatedPdfPath: string | null = null;
if (
seleniumResult &&
seleniumResult.ss_path &&
typeof seleniumResult.ss_path === "string" &&
(seleniumResult.ss_path.endsWith(".png") ||
seleniumResult.ss_path.endsWith(".jpg") ||
seleniumResult.ss_path.endsWith(".jpeg"))
) {
try {
if (!fsSync.existsSync(seleniumResult.ss_path)) {
throw new Error(
`Screenshot file not found: ${seleniumResult.ss_path}`
);
}
pdfBuffer = await imageToPdfBuffer(seleniumResult.ss_path);
const pdfFileName = `ddma_eligibility_${insuranceEligibilityData.memberId}_${Date.now()}.pdf`;
generatedPdfPath = path.join(
path.dirname(seleniumResult.ss_path),
pdfFileName
);
await fs.writeFile(generatedPdfPath, pdfBuffer);
// ensure cleanup uses this
seleniumResult.pdf_path = generatedPdfPath;
} catch (err: any) {
console.error("Failed to convert screenshot to PDF:", err);
outputResult.pdfUploadStatus = `Failed to convert screenshot to PDF: ${String(err)}`;
}
} else {
outputResult.pdfUploadStatus =
"No valid screenshot (ss_path) provided by Selenium; nothing to upload.";
}
if (pdfBuffer && generatedPdfPath) {
const groupTitle = "Eligibility Status";
const groupTitleKey = "ELIGIBILITY_STATUS";
let group = await storage.findPdfGroupByPatientTitleKey(
patient.id,
groupTitleKey
);
if (!group) {
group = await storage.createPdfGroup(
patient.id,
groupTitle,
groupTitleKey
);
}
if (!group?.id) {
throw new Error("PDF group creation failed: missing group ID");
}
const created = await storage.createPdfFile(
group.id,
path.basename(generatedPdfPath),
pdfBuffer
);
if (created && typeof created === "object" && "id" in created) {
createdPdfFileId = Number(created.id);
}
outputResult.pdfUploadStatus = `PDF saved to group: ${group.title}`;
} else {
outputResult.pdfUploadStatus =
"No valid PDF path provided by Selenium, Couldn't upload pdf to server.";
}
return {
patientUpdateStatus: outputResult.patientUpdateStatus,
pdfUploadStatus: outputResult.pdfUploadStatus,
pdfFileId: createdPdfFileId,
};
} catch (err: any) {
return {
patientUpdateStatus: outputResult.patientUpdateStatus,
pdfUploadStatus:
outputResult.pdfUploadStatus ??
`Failed to process DDMA job: ${err?.message ?? String(err)}`,
pdfFileId: createdPdfFileId,
error: err?.message ?? String(err),
};
} finally {
// ALWAYS attempt cleanup of temp files
try {
if (seleniumResult && seleniumResult.pdf_path) {
await emptyFolderContainingFile(seleniumResult.pdf_path);
} else if (seleniumResult && seleniumResult.ss_path) {
await emptyFolderContainingFile(seleniumResult.ss_path);
} else {
console.log(
`[ddma-eligibility] no pdf_path or ss_path available to cleanup`
);
}
} catch (cleanupErr) {
console.error(
`[ddma-eligibility cleanup failed for ${seleniumResult?.pdf_path ?? seleniumResult?.ss_path}]`,
cleanupErr
);
}
}
}
// --- top of file, alongside ddmaJobs ---
let currentFinalSessionId: string | null = null;
let currentFinalResult: any = null;
function now() {
return new Date().toISOString();
}
function log(tag: string, msg: string, ctx?: any) {
console.log(`${now()} [${tag}] ${msg}`, ctx ?? "");
}
function emitSafe(socketId: string | undefined, event: string, payload: any) {
if (!socketId) {
log("socket", "no socketId for emit", { event });
return;
}
try {
const socket = io?.sockets.sockets.get(socketId);
if (!socket) {
log("socket", "socket not found (maybe disconnected)", {
socketId,
event,
});
return;
}
socket.emit(event, payload);
log("socket", "emitted", { socketId, event });
} catch (err: any) {
log("socket", "emit failed", { socketId, event, err: err?.message });
}
}
/**
* Polls Python agent for session status and emits socket events:
* - 'selenium:otp_required' when waiting_for_otp
* - 'selenium:session_update' when completed/error
* - rabsolute timeout + transient error handling.
* - pollTimeoutMs default = 2 minutes (adjust where invoked)
*/
async function pollAgentSessionAndProcess(
sessionId: string,
socketId?: string,
pollTimeoutMs = 2 * 60 * 1000
) {
const maxAttempts = 300;
const baseDelayMs = 1000;
const maxTransientErrors = 12;
// NEW: give up if same non-terminal status repeats this many times
const noProgressLimit = 100;
const job = ddmaJobs[sessionId];
let transientErrorCount = 0;
let consecutiveNoProgress = 0;
let lastStatus: string | null = null;
const deadline = Date.now() + pollTimeoutMs;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// absolute deadline check
if (Date.now() > deadline) {
emitSafe(socketId, "selenium:session_update", {
session_id: sessionId,
status: "error",
message: `Polling timeout reached (${Math.round(pollTimeoutMs / 1000)}s).`,
});
delete ddmaJobs[sessionId];
return;
}
log(
"poller",
`attempt=${attempt} session=${sessionId} transientErrCount=${transientErrorCount}`
);
try {
const st = await getSeleniumDdmaSessionStatus(sessionId);
const status = st?.status ?? null;
log("poller", "got status", {
sessionId,
status,
message: st?.message,
resultKeys: st?.result ? Object.keys(st.result) : null,
});
// reset transient errors on success
transientErrorCount = 0;
// if status unchanged and non-terminal, increment no-progress counter
const isTerminalLike =
status === "completed" || status === "error" || status === "not_found";
if (status === lastStatus && !isTerminalLike) {
consecutiveNoProgress++;
} else {
consecutiveNoProgress = 0;
}
lastStatus = status;
// if no progress for too many consecutive polls -> abort
if (consecutiveNoProgress >= noProgressLimit) {
emitSafe(socketId, "selenium:session_update", {
session_id: sessionId,
status: "error",
message: `No progress from selenium agent (status="${status}") after ${consecutiveNoProgress} polls; aborting.`,
});
emitSafe(socketId, "selenium:session_error", {
session_id: sessionId,
status: "error",
message: "No progress from selenium agent",
});
delete ddmaJobs[sessionId];
return;
}
// always emit debug to client if socket exists
emitSafe(socketId, "selenium:debug", {
session_id: sessionId,
attempt,
status,
serverTime: new Date().toISOString(),
});
// If agent is waiting for OTP, inform client but keep polling (do not return)
if (status === "waiting_for_otp") {
emitSafe(socketId, "selenium:otp_required", {
session_id: sessionId,
message: "OTP required. Please enter the OTP.",
});
// do not return — keep polling (allows same poller to pick up completion)
await new Promise((r) => setTimeout(r, baseDelayMs));
continue;
}
// Completed path
if (status === "completed") {
log("poller", "agent completed; processing result", {
sessionId,
resultKeys: st.result ? Object.keys(st.result) : null,
});
// Persist raw result so frontend can fetch if socket disconnects
currentFinalSessionId = sessionId;
currentFinalResult = {
rawSelenium: st.result,
processedAt: null,
final: null,
};
let finalResult: any = null;
if (job && st.result) {
try {
finalResult = await handleDdmaCompletedJob(
sessionId,
job,
st.result
);
currentFinalResult.final = finalResult;
currentFinalResult.processedAt = Date.now();
} catch (err: any) {
currentFinalResult.final = {
error: "processing_failed",
detail: err?.message ?? String(err),
};
currentFinalResult.processedAt = Date.now();
log("poller", "handleDdmaCompletedJob failed", {
sessionId,
err: err?.message ?? err,
});
}
} else {
currentFinalResult[sessionId].final = {
error: "no_job_or_no_result",
};
currentFinalResult[sessionId].processedAt = Date.now();
}
// Emit final update (if socket present)
emitSafe(socketId, "selenium:session_update", {
session_id: sessionId,
status: "completed",
rawSelenium: st.result,
final: currentFinalResult.final,
});
// cleanup job context
delete ddmaJobs[sessionId];
return;
}
// Terminal error / not_found
if (status === "error" || status === "not_found") {
const emitPayload = {
session_id: sessionId,
status,
message: st?.message || "Selenium session error",
};
emitSafe(socketId, "selenium:session_update", emitPayload);
emitSafe(socketId, "selenium:session_error", emitPayload);
delete ddmaJobs[sessionId];
return;
}
} catch (err: any) {
const axiosStatus =
err?.response?.status ?? (err?.status ? Number(err.status) : undefined);
const errCode = err?.code ?? err?.errno;
const errMsg = err?.message ?? String(err);
const errData = err?.response?.data ?? null;
// If agent explicitly returned 404 -> terminal (session gone)
if (
axiosStatus === 404 ||
(typeof errMsg === "string" && errMsg.includes("not_found"))
) {
console.warn(
`${new Date().toISOString()} [poller] terminal 404/not_found for ${sessionId}: data=${JSON.stringify(errData)}`
);
// Emit not_found to client
const emitPayload = {
session_id: sessionId,
status: "not_found",
message:
errData?.detail || "Selenium session not found (agent cleaned up).",
};
emitSafe(socketId, "selenium:session_update", emitPayload);
emitSafe(socketId, "selenium:session_error", emitPayload);
// Remove job context and stop polling
delete ddmaJobs[sessionId];
return;
}
// Detailed transient error logging
transientErrorCount++;
if (transientErrorCount > maxTransientErrors) {
const emitPayload = {
session_id: sessionId,
status: "error",
message:
"Repeated network errors while polling selenium agent; giving up.",
};
emitSafe(socketId, "selenium:session_update", emitPayload);
emitSafe(socketId, "selenium:session_error", emitPayload);
delete ddmaJobs[sessionId];
return;
}
const backoffMs = Math.min(
30_000,
baseDelayMs * Math.pow(2, transientErrorCount - 1)
);
console.warn(
`${new Date().toISOString()} [poller] transient error (#${transientErrorCount}) for ${sessionId}: code=${errCode} status=${axiosStatus} msg=${errMsg} data=${JSON.stringify(errData)}`
);
console.warn(
`${new Date().toISOString()} [poller] backing off ${backoffMs}ms before next attempt`
);
await new Promise((r) => setTimeout(r, backoffMs));
continue;
}
// normal poll interval
await new Promise((r) => setTimeout(r, baseDelayMs));
}
// overall timeout fallback
emitSafe(socketId, "selenium:session_update", {
session_id: sessionId,
status: "error",
message: "Polling timeout while waiting for selenium session",
});
delete ddmaJobs[sessionId];
}
/**
* POST /ddma-eligibility
* Starts DDMA eligibility Selenium job.
* Expects:
* - req.body.data: stringified JSON like your existing /eligibility-check
* - req.body.socketId: socket.io client id
*/
router.post(
"/ddma-eligibility",
async (req: Request, res: Response): Promise<any> => {
if (!req.body.data) {
return res
.status(400)
.json({ error: "Missing Insurance Eligibility data for selenium" });
}
if (!req.user || !req.user.id) {
return res.status(401).json({ error: "Unauthorized: user info missing" });
}
try {
const rawData =
typeof req.body.data === "string"
? JSON.parse(req.body.data)
: req.body.data;
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
req.user.id,
rawData.insuranceSiteKey
);
if (!credentials) {
return res.status(404).json({
error:
"No insurance credentials found for this provider, Kindly Update this at Settings Page.",
});
}
const enrichedData = {
...rawData,
massddmaUsername: credentials.username,
massddmaPassword: credentials.password,
};
const socketId: string | undefined = req.body.socketId;
const agentResp =
await forwardToSeleniumDdmaEligibilityAgent(enrichedData);
if (
!agentResp ||
agentResp.status !== "started" ||
!agentResp.session_id
) {
return res.status(502).json({
error: "Selenium agent did not return a started session",
detail: agentResp,
});
}
const sessionId = agentResp.session_id as string;
// Save job context
ddmaJobs[sessionId] = {
userId: req.user.id,
insuranceEligibilityData: enrichedData,
socketId,
};
// start polling in background to notify client via socket and process job
pollAgentSessionAndProcess(sessionId, socketId).catch((e) =>
console.warn("pollAgentSessionAndProcess failed", e)
);
// reply immediately with started status
return res.json({ status: "started", session_id: sessionId });
} catch (err: any) {
console.error(err);
return res.status(500).json({
error: err.message || "Failed to start ddma selenium agent",
});
}
}
);
/**
* POST /selenium/submit-otp
* Body: { session_id, otp, socketId? }
* Forwards OTP to Python agent and optionally notifies client socket.
*/
router.post(
"/selenium/submit-otp",
async (req: Request, res: Response): Promise<any> => {
const { session_id: sessionId, otp, socketId } = req.body;
if (!sessionId || !otp) {
return res.status(400).json({ error: "session_id and otp are required" });
}
try {
const r = await forwardOtpToSeleniumDdmaAgent(sessionId, otp);
// emit OTP accepted (if socket present)
emitSafe(socketId, "selenium:otp_submitted", {
session_id: sessionId,
result: r,
});
return res.json(r);
} catch (err: any) {
console.error(
"Failed to forward OTP:",
err?.response?.data || err?.message || err
);
return res.status(500).json({
error: "Failed to forward otp to selenium agent",
detail: err?.message || err,
});
}
}
);
// GET /selenium/session/:sid/final
router.get(
"/selenium/session/:sid/final",
async (req: Request, res: Response) => {
const sid = req.params.sid;
if (!sid) return res.status(400).json({ error: "session id required" });
// Only the current in-memory result is available
if (currentFinalSessionId !== sid || !currentFinalResult) {
return res.status(404).json({ error: "final result not found" });
}
return res.json(currentFinalResult);
}
);
export default router;

View File

@@ -0,0 +1,69 @@
import { Router, Request, Response } from "express";
import { storage } from "../storage";
const router = Router();
router.get("/", async (req: Request, res: Response): Promise<any> => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const notifications = await storage.getNotifications(userId, 20, 0);
res.json(notifications);
} catch (err) {
console.error("Failed to fetch notifications:", err);
res.status(500).json({ message: "Failed to fetch notifications" });
}
});
// Mark one notification as read
router.post("/:id/read", async (req: Request, res: Response): Promise<any> => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const success = await storage.markNotificationRead(
userId,
Number(req.params.id)
);
if (!success)
return res.status(404).json({ message: "Notification not found" });
res.json({ success: true });
} catch (err) {
console.error("Failed to mark notification as read:", err);
res.status(500).json({ message: "Failed to mark notification as read" });
}
});
// Mark all notifications as read
router.post("/read-all", async (req: Request, res: Response): Promise<any> => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const count = await storage.markAllNotificationsRead(userId);
res.json({ success: true, updatedCount: count });
} catch (err) {
console.error("Failed to mark all notifications read:", err);
res.status(500).json({ message: "Failed to mark all notifications read" });
}
});
router.delete(
"/delete-all",
async (req: Request, res: Response): Promise<any> => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const deletedCount = await storage.deleteAllNotifications(userId);
res.json({ success: true, deletedCount });
} catch (err) {
console.error("Failed to delete notifications:", err);
res.status(500).json({ message: "Failed to delete notifications" });
}
}
);
export default router;

View File

@@ -0,0 +1,99 @@
import express, { Request, Response } from "express";
import { z } from "zod";
import { storage } from "../storage";
import { insertNpiProviderSchema } from "@repo/db/types";
const router = express.Router();
router.get("/", async (req: Request, res: Response) => {
try {
if (!req.user?.id) {
return res.status(401).json({ message: "Unauthorized" });
}
const providers = await storage.getNpiProvidersByUser(req.user.id);
res.status(200).json(providers);
} catch (err) {
res.status(500).json({
error: "Failed to fetch NPI providers",
details: String(err),
});
}
});
router.post("/", async (req: Request, res: Response) => {
try {
if (!req.user?.id) {
return res.status(401).json({ message: "Unauthorized" });
}
const parsed = insertNpiProviderSchema.safeParse({
...req.body,
userId: req.user.id,
});
if (!parsed.success) {
const flat = parsed.error.flatten();
const firstError =
Object.values(flat.fieldErrors)[0]?.[0] || "Invalid input";
return res.status(400).json({
message: firstError,
details: flat.fieldErrors,
});
}
const provider = await storage.createNpiProvider(parsed.data);
res.status(201).json(provider);
} catch (err: any) {
if (err.code === "P2002") {
return res.status(400).json({
message: "This NPI already exists for the user",
});
}
res.status(500).json({
error: "Failed to create NPI provider",
details: String(err),
});
}
});
router.put("/:id", async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
if (isNaN(id)) return res.status(400).send("Invalid ID");
const provider = await storage.updateNpiProvider(id, req.body);
res.status(200).json(provider);
} catch (err) {
res.status(500).json({
error: "Failed to update NPI provider",
details: String(err),
});
}
});
router.delete("/:id", async (req: Request, res: Response) => {
try {
if (!req.user?.id) {
return res.status(401).json({ message: "Unauthorized" });
}
const id = Number(req.params.id);
if (isNaN(id)) return res.status(400).send("Invalid ID");
const ok = await storage.deleteNpiProvider(req.user.id, id);
if (!ok) {
return res.status(404).json({ message: "NPI provider not found" });
}
res.status(204).send();
} catch (err) {
res.status(500).json({
error: "Failed to delete NPI provider",
details: String(err),
});
}
});
export default router;

View File

@@ -0,0 +1,258 @@
import { Router } from "express";
import { Request, Response } from "express";
import { storage } from "../storage";
import multer from "multer";
import { z } from "zod";
const router = Router();
// Configure multer for file uploads
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10MB limit
},
fileFilter: (req, file, cb) => {
// Accept common document and image formats
const allowedTypes = [
'application/pdf',
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain',
];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Invalid file type. Only PDF, images, and documents are allowed.'));
}
}
});
// Validation schemas
const uploadDocumentSchema = z.object({
patientId: z.string().transform((val) => parseInt(val, 10)),
});
const getDocumentsSchema = z.object({
patientId: z.string().transform((val) => parseInt(val, 10)),
limit: z.string().optional().transform((val) => val ? parseInt(val, 10) : undefined),
offset: z.string().optional().transform((val) => val ? parseInt(val, 10) : undefined),
});
const deleteDocumentSchema = z.object({
id: z.string().transform((val) => parseInt(val, 10)),
});
// POST /api/patient-documents/upload
// Upload a document for a specific patient
router.post("/upload", upload.single("file"), async (req: Request, res: Response): Promise<any> => {
try {
const { patientId } = uploadDocumentSchema.parse(req.body);
const file = req.file;
if (!file) {
return res.status(400).json({ error: "No file uploaded" });
}
const document = await storage.createPatientDocument(
patientId,
file.originalname,
file.originalname,
file.mimetype,
file.size,
file.buffer
);
res.status(201).json({
success: true,
document: {
...document,
fileSize: Number(document.fileSize), // Convert BigInt to Number for JSON serialization
}
});
} catch (error) {
console.error("Error uploading document:", error);
if (error instanceof z.ZodError) {
return res.status(400).json({ error: "Invalid request data", details: error.errors });
}
if (error instanceof Error && error.message.includes('Invalid file type')) {
return res.status(400).json({ error: error.message });
}
res.status(500).json({ error: "Internal server error" });
}
});
// GET /api/patient-documents/patient/:patientId
// Get all documents for a specific patient
router.get("/patient/:patientId", async (req: Request, res: Response): Promise<any> => {
try {
const { patientId, limit, offset } = getDocumentsSchema.parse({
patientId: req.params.patientId,
limit: req.query.limit,
offset: req.query.offset,
});
if (limit !== undefined && offset !== undefined) {
// Paginated response
const result = await storage.getDocumentsByPatientIdPaginated(patientId, limit, offset);
res.json({
success: true,
documents: result.documents.map(doc => ({
...doc,
fileSize: Number(doc.fileSize), // Convert BigInt to Number
})),
total: result.total,
});
} else {
// Non-paginated response
const documents = await storage.getDocumentsByPatientId(patientId);
res.json({
success: true,
documents: documents.map(doc => ({
...doc,
fileSize: Number(doc.fileSize), // Convert BigInt to Number
})),
});
}
} catch (error) {
console.error("Error fetching documents:", error);
if (error instanceof z.ZodError) {
return res.status(400).json({ error: "Invalid patient ID", details: error.errors });
}
res.status(500).json({ error: "Internal server error" });
}
});
// GET /api/patient-documents/:id/download
// Download a specific document
router.get("/:id/download", async (req: Request, res: Response): Promise<any> => {
try {
const { id } = deleteDocumentSchema.parse({ id: req.params.id });
const result = await storage.getDocumentFile(id);
if (!result) {
return res.status(404).json({ error: "Document not found" });
}
const { buffer, document } = result;
// Set appropriate headers
res.setHeader("Content-Type", document.mimeType);
res.setHeader("Content-Length", document.fileSize.toString());
res.setHeader(
"Content-Disposition",
`attachment; filename="${encodeURIComponent(document.originalName)}"`
);
res.send(buffer);
} catch (error) {
console.error("Error downloading document:", error);
if (error instanceof z.ZodError) {
return res.status(400).json({ error: "Invalid document ID", details: error.errors });
}
res.status(500).json({ error: "Internal server error" });
}
});
// GET /api/patient-documents/:id/view
// View a specific document (inline display)
router.get("/:id/view", async (req: Request, res: Response): Promise<any> => {
try {
const { id } = deleteDocumentSchema.parse({ id: req.params.id });
const result = await storage.getDocumentFile(id);
if (!result) {
return res.status(404).json({ error: "Document not found" });
}
const { buffer, document } = result;
// Set appropriate headers for inline viewing
res.setHeader("Content-Type", document.mimeType);
res.setHeader("Content-Length", document.fileSize.toString());
res.setHeader(
"Content-Disposition",
`inline; filename="${encodeURIComponent(document.originalName)}"`
);
res.send(buffer);
} catch (error) {
console.error("Error viewing document:", error);
if (error instanceof z.ZodError) {
return res.status(400).json({ error: "Invalid document ID", details: error.errors });
}
res.status(500).json({ error: "Internal server error" });
}
});
// DELETE /api/patient-documents/:id
// Delete a specific document
router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
try {
const { id } = deleteDocumentSchema.parse({ id: req.params.id });
const success = await storage.deleteDocument(id);
if (!success) {
return res.status(404).json({ error: "Document not found" });
}
res.json({ success: true, message: "Document deleted successfully" });
} catch (error) {
console.error("Error deleting document:", error);
if (error instanceof z.ZodError) {
return res.status(400).json({ error: "Invalid document ID", details: error.errors });
}
res.status(500).json({ error: "Internal server error" });
}
});
// POST /api/patient-documents/scan
// Simulate document scanning (placeholder for actual scanner integration)
router.post("/scan", async (req: Request, res: Response): Promise<any> => {
try {
const { patientId } = uploadDocumentSchema.parse(req.body);
// This is a placeholder for actual scanner integration
// In a real implementation, you would:
// 1. Interface with scanner hardware/software
// 2. Capture the scanned image
// 3. Process and save the image
// 4. Return the document info
res.json({
success: true,
message: "Scanner interface ready. Please integrate with your scanner hardware/software.",
patientId,
note: "This endpoint requires integration with scanner hardware/software SDK."
});
} catch (error) {
console.error("Error scanning document:", error);
if (error instanceof z.ZodError) {
return res.status(400).json({ error: "Invalid request data", details: error.errors });
}
res.status(500).json({ error: "Internal server error" });
}
});
export default router;

View File

@@ -0,0 +1,23 @@
import { Router } from "express";
import type { Request, Response } from "express";
const router = Router();
import multer from "multer";
import forwardToPatientDataExtractorService from "../services/patientDataExtractorService";
const upload = multer({ storage: multer.memoryStorage() });
router.post("/patientdataextract", upload.single("pdf"), async (req: Request, res: Response): Promise<any>=> {
if (!req.file) {
return res.status(400).json({ error: "No PDF file uploaded." });
}
try {
const result = await forwardToPatientDataExtractorService(req.file);
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ error: "Extraction failed" });
}
});
export default router;

View File

@@ -0,0 +1,376 @@
import { Router } from "express";
import type { Request, Response } from "express";
import { storage } from "../storage";
import { z } from "zod";
import { insertPatientSchema, updatePatientSchema } from "@repo/db/types";
import { normalizeInsuranceId } from "../utils/helpers";
const router = Router();
// Patient Routes
// Get all patients for the logged-in user
router.get("/", async (req, res) => {
try {
const patients = await storage.getPatientsByUserId(req.user!.id);
res.json(patients);
} catch (error) {
res.status(500).json({ message: "Failed to retrieve patients" });
}
});
// Get recent patients (paginated)
router.get("/recent", async (req: Request, res: Response) => {
try {
const limit = parseInt(req.query.limit as string) || 10;
const offset = parseInt(req.query.offset as string) || 0;
const [patients, totalCount] = await Promise.all([
storage.getRecentPatients(limit, offset),
storage.getTotalPatientCount(),
]);
res.json({ patients, totalCount });
} catch (error) {
console.error("Failed to retrieve recent patients:", error);
res.status(500).json({ message: "Failed to retrieve recent patients" });
}
});
router.get("/search", async (req: Request, res: Response): Promise<any> => {
try {
const {
name,
phone,
insuranceId,
gender,
dob,
term,
limit = "10",
offset = "0",
} = req.query as Record<string, string>;
const filters: any = {};
if (term) {
filters.OR = [
{ firstName: { contains: term, mode: "insensitive" } },
{ lastName: { contains: term, mode: "insensitive" } },
{ phone: { contains: term, mode: "insensitive" } },
{ insuranceId: { contains: term, mode: "insensitive" } },
];
}
if (name) {
filters.OR = [
{ firstName: { contains: name, mode: "insensitive" } },
{ lastName: { contains: name, mode: "insensitive" } },
];
}
if (phone) {
filters.phone = { contains: phone, mode: "insensitive" };
}
if (insuranceId) {
filters.insuranceId = { contains: insuranceId, mode: "insensitive" };
}
if (gender) {
filters.gender = gender;
}
if (dob) {
const parsed = new Date(dob);
if (isNaN(parsed.getTime())) {
return res.status(400).json({
message: "Invalid date format for DOB. Use format: YYYY-MM-DD",
});
}
// Match exact dateOfBirth (optional: adjust for timezone)
filters.dateOfBirth = parsed;
}
const [patients, totalCount] = await Promise.all([
storage.searchPatients({
filters,
limit: parseInt(limit),
offset: parseInt(offset),
}),
storage.countPatients(filters),
]);
return res.json({ patients, totalCount });
} catch (error) {
console.error("Search error:", error);
return res.status(500).json({ message: "Failed to search patients" });
}
});
// get patient by insurance id
router.get(
"/by-insurance-id",
async (req: Request, res: Response): Promise<any> => {
const insuranceId = req.query.insuranceId?.toString();
if (!insuranceId) {
return res.status(400).json({ error: "Missing insuranceId" });
}
try {
const patient = await storage.getPatientByInsuranceId(insuranceId);
if (patient) {
return res.status(200).json(patient);
} else {
return res.status(200).json(null);
}
} catch (err) {
console.error("Failed to lookup patient:", err);
return res.status(500).json({ error: "Internal server error" });
}
}
);
// GET /api/patients/:id/financials?limit=50&offset=0
router.get(
"/:id/financials",
async (req: Request, res: Response): Promise<any> => {
try {
const patientIdParam = req.params.id;
if (!patientIdParam)
return res.status(400).json({ message: "Patient ID required" });
const patientId = parseInt(patientIdParam, 10);
if (isNaN(patientId))
return res.status(400).json({ message: "Invalid patient ID" });
const limit = Math.min(1000, Number(req.query.limit ?? 50)); // cap maximums
const offset = Math.max(0, Number(req.query.offset ?? 0));
const { rows, totalCount } = await storage.getPatientFinancialRows(
patientId,
limit,
offset
);
return res.json({ rows, totalCount, limit, offset });
} catch (err) {
console.error("Failed to fetch financial rows:", err);
return res
.status(500)
.json({ message: "Failed to fetch financial rows" });
}
}
);
// Get a single patient by ID
router.get(
"/:id",
async (req: Request, res: Response): Promise<any> => {
try {
const patientIdParam = req.params.id;
// Ensure that patientIdParam exists and is a valid number
if (!patientIdParam) {
return res.status(400).json({ message: "Patient ID is required" });
}
const patientId = parseInt(patientIdParam);
const patient = await storage.getPatient(patientId);
if (!patient) {
return res.status(404).json({ message: "Patient not found" });
}
res.json(patient);
} catch (error) {
res.status(500).json({ message: "Failed to retrieve patient" });
}
}
);
// Create a new patient
router.post("/", async (req: Request, res: Response): Promise<any> => {
try {
const body: any = { ...req.body, userId: req.user!.id };
// Normalize insuranceId early and return clear error if invalid
try {
const normalized = normalizeInsuranceId(body.insuranceId);
body.insuranceId = normalized;
} catch (err: any) {
return res.status(400).json({
message: "Invalid insuranceId",
details: err?.message ?? "Invalid insuranceId format",
});
}
// Validate request body
const patientData = insertPatientSchema.parse({
...req.body,
userId: req.user!.id,
});
// Check for duplicate insuranceId if it's provided
if (patientData.insuranceId) {
const existingPatient = await storage.getPatientByInsuranceId(
patientData.insuranceId as string
);
if (existingPatient) {
return res.status(409).json({
message: "A patient with this insurance ID already exists.",
});
}
}
const patient = await storage.createPatient(patientData);
res.status(201).json(patient);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
message: "Validation error",
errors: error.format(),
});
}
res.status(500).json({ message: "Failed to create patient" });
}
});
// Update an existing patient
router.put(
"/:id",
async (req: Request, res: Response): Promise<any> => {
try {
const patientIdParam = req.params.id;
// Normalize incoming insuranceId (if present)
try {
if (req.body.insuranceId !== undefined) {
req.body.insuranceId = normalizeInsuranceId(req.body.insuranceId);
}
} catch (err: any) {
return res.status(400).json({
message: "Invalid insuranceId",
details: err?.message ?? "Invalid insuranceId format",
});
}
// Ensure that patientIdParam exists and is a valid number
if (!patientIdParam) {
return res.status(400).json({ message: "Patient ID is required" });
}
const patientId = parseInt(patientIdParam);
// Check if patient exists and belongs to user
const existingPatient = await storage.getPatient(patientId);
if (!existingPatient) {
return res.status(404).json({ message: "Patient not found" });
}
// Validate request body
const patientData = updatePatientSchema.parse(req.body);
// If updating insuranceId, check for uniqueness (excluding self)
if (
patientData.insuranceId &&
patientData.insuranceId !== existingPatient.insuranceId
) {
const duplicatePatient = await storage.getPatientByInsuranceId(
patientData.insuranceId as string
);
if (duplicatePatient && duplicatePatient.id !== patientId) {
return res.status(409).json({
message: "Another patient with this insurance ID already exists.",
});
}
}
// Update patient
const updatedPatient = await storage.updatePatient(
patientId,
patientData
);
res.json(updatedPatient);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
message: "Validation error",
errors: error.format(),
});
}
res.status(500).json({ message: "Failed to update patient" });
}
}
);
// Delete a patient
router.delete(
"/:id",
async (req: Request, res: Response): Promise<any> => {
try {
const patientIdParam = req.params.id;
// Ensure that patientIdParam exists and is a valid number
if (!patientIdParam) {
return res.status(400).json({ message: "Patient ID is required" });
}
const patientId = parseInt(patientIdParam);
// Check if patient exists and belongs to user
const existingPatient = await storage.getPatient(patientId);
if (!existingPatient) {
return res.status(404).json({ message: "Patient not found" });
}
if (existingPatient.userId !== req.user!.id) {
return res.status(403).json({
message:
"Forbidden: Patient belongs to a different user, you can't delete this.",
});
}
// Delete patient
await storage.deletePatient(patientId);
res.status(204).send();
} catch (error: any) {
console.error("Delete patient error:", error);
res.status(500).json({ message: "Failed to delete patient" });
}
}
);
// Get appointments for a specific patient
router.get(
"/:patientId/appointments",
async (req: Request, res: Response): Promise<any> => {
try {
const patientIdParam = req.params.id;
// Ensure that patientIdParam exists and is a valid number
if (!patientIdParam) {
return res.status(400).json({ message: "Patient ID is required" });
}
const patientId = parseInt(patientIdParam);
// Check if patient exists and belongs to user
const patient = await storage.getPatient(patientId);
if (!patient) {
return res.status(404).json({ message: "Patient not found" });
}
const appointments = await storage.getAppointmentsByPatientId(patientId);
res.json(appointments);
} catch (error) {
res.status(500).json({ message: "Failed to retrieve appointments" });
}
}
);
export default router;

View File

@@ -0,0 +1,50 @@
import { Router, Request, Response } from "express";
import multer from "multer";
import { forwardToPaymentOCRService } from "../services/paymentOCRService";
const router = Router();
// keep files in memory; FastAPI accepts them as multipart bytes
const upload = multer({ storage: multer.memoryStorage() });
// POST /payment-ocr/extract (field name: "files")
router.post(
"/extract",
upload.array("files"), // allow multiple images
async (req: Request, res: Response): Promise<any> => {
try {
const files = req.files as Express.Multer.File[] | undefined;
if (!files || files.length === 0) {
return res
.status(400)
.json({ error: "No image files uploaded. Use field name 'files'." });
}
// (optional) basic client-side MIME guard
const allowed = new Set([
"image/jpeg",
"image/png",
"image/tiff",
"image/bmp",
"image/jpg",
]);
const bad = files.filter((f) => !allowed.has(f.mimetype.toLowerCase()));
if (bad.length) {
return res.status(415).json({
error: `Unsupported file types: ${bad
.map((b) => b.originalname)
.join(", ")}`,
});
}
const rows = await forwardToPaymentOCRService(files);
return res.json({ rows });
} catch (err) {
console.error(err);
return res.status(500).json({ error: "Payment OCR extraction failed" });
}
}
);
export default router;

View File

@@ -0,0 +1,213 @@
import { Router } from "express";
import type { Request, Response } from "express";
import { storage } from "../storage";
const router = Router();
/**
* GET /api/payments-reports/summary
* optional query: from=YYYY-MM-DD&to=YYYY-MM-DD (ISO date strings)
*/
router.get("/summary", async (req: Request, res: Response): Promise<any> => {
try {
const from = req.query.from ? new Date(String(req.query.from)) : undefined;
const to = req.query.to ? new Date(String(req.query.to)) : undefined;
if (req.query.from && isNaN(from?.getTime() ?? NaN))
return res.status(400).json({ message: "Invalid 'from' date" });
if (req.query.to && isNaN(to?.getTime() ?? NaN))
return res.status(400).json({ message: "Invalid 'to' date" });
const summary = await storage.getSummary(from, to);
res.json(summary);
} catch (err: any) {
console.error(
"GET /api/payments-reports/summary error:",
err?.message ?? err,
err?.stack
);
res.status(500).json({ message: "Failed to fetch dashboard summary" });
}
});
/**
* GET /api/payments-reports/patient-balances
* query:
* - limit (default 25)
* - cursor (optional base64 cursor token)
* - from / to (optional ISO date strings) - filter payments by createdAt in the range (inclusive)
*/
router.get(
"/patients-with-balances",
async (req: Request, res: Response): Promise<any> => {
try {
const limit = Math.max(
1,
Math.min(200, parseInt(String(req.query.limit || "25"), 10))
);
const cursor =
typeof req.query.cursor === "string" ? String(req.query.cursor) : null;
const from = req.query.from
? new Date(String(req.query.from))
: undefined;
const to = req.query.to ? new Date(String(req.query.to)) : undefined;
if (req.query.from && isNaN(from?.getTime() ?? NaN)) {
return res.status(400).json({ message: "Invalid 'from' date" });
}
if (req.query.to && isNaN(to?.getTime() ?? NaN)) {
return res.status(400).json({ message: "Invalid 'to' date" });
}
const data = await storage.getPatientsWithBalances(
limit,
cursor,
from,
to
);
// returns { balances, totalCount, nextCursor, hasMore }
res.json(data);
} catch (err: any) {
console.error(
"GET /api/payments-reports/patient-balances error:",
err?.message ?? err,
err?.stack
);
res.status(500).json({ message: "Failed to fetch patient balances" });
}
}
);
/**
* GET /api/payments-reports/by-doctor/balances
* Query params:
* - staffId (required)
* - limit (optional, default 25)
* - cursor (optional)
* - from/to (optional ISO date strings) - filter payments by createdAt in the range (inclusive)
*
* Response: { balances, totalCount, nextCursor, hasMore }
*/
router.get(
"/by-doctor/balances",
async (req: Request, res: Response): Promise<any> => {
try {
const staffIdRaw = req.query.staffId;
if (!staffIdRaw) {
return res
.status(400)
.json({ message: "Missing required 'staffId' query parameter" });
}
const staffId = Number(staffIdRaw);
if (!Number.isFinite(staffId) || staffId <= 0) {
return res
.status(400)
.json({ message: "Invalid 'staffId' query parameter" });
}
const limit = Math.max(
1,
Math.min(200, parseInt(String(req.query.limit || "25"), 10))
);
const cursor =
typeof req.query.cursor === "string" ? String(req.query.cursor) : null;
const from = req.query.from
? new Date(String(req.query.from))
: undefined;
const to = req.query.to ? new Date(String(req.query.to)) : undefined;
if (req.query.from && isNaN(from?.getTime() ?? NaN)) {
return res.status(400).json({ message: "Invalid 'from' date" });
}
if (req.query.to && isNaN(to?.getTime() ?? NaN)) {
return res.status(400).json({ message: "Invalid 'to' date" });
}
// use the new storage method that returns only the paged balances
const balancesResult = await storage.getPatientsBalancesByDoctor(
staffId,
limit,
cursor,
from,
to
);
res.json({
balances: balancesResult?.balances ?? [],
totalCount: Number(balancesResult?.totalCount ?? 0),
nextCursor: balancesResult?.nextCursor ?? null,
hasMore: Boolean(balancesResult?.hasMore ?? false),
});
} catch (err: any) {
console.error(
"GET /api/payments-reports/by-doctor/balances error:",
err?.message ?? err,
err?.stack
);
res.status(500).json({
message: "Failed to fetch doctor balances",
detail: err?.message ?? String(err),
});
}
}
);
/**
* GET /api/payments-reports/by-doctor/summary
* Query params:
* - staffId (required)
* - from/to (optional ISO date strings) - filter payments by createdAt in the range (inclusive)
*
* Response: { totalPatients, totalOutstanding, totalCollected, patientsWithBalance }
*/
router.get(
"/by-doctor/summary",
async (req: Request, res: Response): Promise<any> => {
try {
const staffIdRaw = req.query.staffId;
if (!staffIdRaw) {
return res
.status(400)
.json({ message: "Missing required 'staffId' query parameter" });
}
const staffId = Number(staffIdRaw);
if (!Number.isFinite(staffId) || staffId <= 0) {
return res
.status(400)
.json({ message: "Invalid 'staffId' query parameter" });
}
const from = req.query.from
? new Date(String(req.query.from))
: undefined;
const to = req.query.to ? new Date(String(req.query.to)) : undefined;
if (req.query.from && isNaN(from?.getTime() ?? NaN)) {
return res.status(400).json({ message: "Invalid 'from' date" });
}
if (req.query.to && isNaN(to?.getTime() ?? NaN)) {
return res.status(400).json({ message: "Invalid 'to' date" });
}
// use the new storage method that returns only the summary for the staff
const summary = await storage.getSummaryByDoctor(staffId, from, to);
res.json(summary);
} catch (err: any) {
console.error(
"GET /api/payments-reports/by-doctor/summary error:",
err?.message ?? err,
err?.stack
);
res.status(500).json({
message: "Failed to fetch doctor summary",
detail: err?.message ?? String(err),
});
}
}
);
export default router;

View File

@@ -0,0 +1,459 @@
import { Router } from "express";
import { Request, Response } from "express";
import { storage } from "../storage";
import { z } from "zod";
import { ZodError } from "zod";
import {
insertPaymentSchema,
NewTransactionPayload,
newTransactionPayloadSchema,
paymentMethodOptions,
PaymentStatus,
paymentStatusOptions,
claimStatusOptions,
} from "@repo/db/types";
import { prisma } from "@repo/db/client";
import { PaymentStatusSchema } from "@repo/db/types";
import * as paymentService from "../services/paymentService";
const paymentFilterSchema = z.object({
from: z.string().datetime(),
to: z.string().datetime(),
});
function parseIntOrError(input: string | undefined, name: string) {
if (!input) throw new Error(`${name} is required`);
const value = parseInt(input, 10);
if (isNaN(value)) throw new Error(`${name} must be a valid number`);
return value;
}
export function handleRouteError(
res: Response,
error: unknown,
defaultMsg: string
) {
if (error instanceof ZodError) {
return res.status(400).json({
message: "Validation error",
errors: error.format(),
});
}
const msg = error instanceof Error ? error.message : defaultMsg;
return res.status(500).json({ message: msg });
}
const router = Router();
// GET /api/payments/recent
router.get("/recent", async (req: Request, res: Response): Promise<any> => {
try {
const limit = parseInt(req.query.limit as string) || 10;
const offset = parseInt(req.query.offset as string) || 0;
const [payments, totalCount] = await Promise.all([
storage.getRecentPayments(limit, offset),
storage.getTotalPaymentCount(),
]);
res.status(200).json({ payments, totalCount });
} catch (err) {
console.error("Failed to fetch payments:", err);
res.status(500).json({ message: "Failed to fetch recent payments" });
}
});
// GET /api/payments/claim/:claimId
router.get(
"/claim/:claimId",
async (req: Request, res: Response): Promise<any> => {
try {
const parsedClaimId = parseIntOrError(req.params.claimId, "Claim ID");
const payments = await storage.getPaymentsByClaimId(parsedClaimId);
if (!payments)
return res.status(404).json({ message: "No payments found for claim" });
res.status(200).json(payments);
} catch (error) {
console.error("Error fetching payments:", error);
res.status(500).json({ message: "Failed to retrieve payments" });
}
}
);
// GET /api/payments/patient/:patientId
router.get(
"/patient/:patientId",
async (req: Request, res: Response): Promise<any> => {
try {
const patientIdParam = req.params.patientId;
if (!patientIdParam) {
return res.status(400).json({ message: "Missing patientId" });
}
const patientId = parseInt(patientIdParam);
if (isNaN(patientId)) {
return res.status(400).json({ message: "Invalid patientId" });
}
const limit = parseInt(req.query.limit as string) || 10;
const offset = parseInt(req.query.offset as string) || 0;
if (isNaN(patientId)) {
return res.status(400).json({ message: "Invalid patient ID" });
}
const [payments, totalCount] = await Promise.all([
storage.getRecentPaymentsByPatientId(patientId, limit, offset),
storage.getTotalPaymentCountByPatient(patientId),
]);
res.json({ payments, totalCount });
} catch (error) {
console.error("Failed to retrieve payments for patient:", error);
res.status(500).json({ message: "Failed to retrieve patient payments" });
}
}
);
// GET /api/payments/filter
router.get("/filter", async (req: Request, res: Response): Promise<any> => {
try {
const validated = paymentFilterSchema.safeParse(req.query);
if (!validated.success) {
return res.status(400).json({
message: "Invalid date format",
errors: validated.error.errors,
});
}
const { from, to } = validated.data;
const payments = await storage.getPaymentsByDateRange(
new Date(from),
new Date(to)
);
res.status(200).json(payments);
} catch (err) {
console.error("Failed to filter payments:", err);
res.status(500).json({ message: "Server error" });
}
});
// GET /api/payments/:id
router.get("/:id", async (req: Request, res: Response): Promise<any> => {
try {
const id = parseIntOrError(req.params.id, "Payment ID");
const payment = await storage.getPaymentById(id);
if (!payment) return res.status(404).json({ message: "Payment not found" });
res.status(200).json(payment);
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to retrieve payment";
res.status(500).json({ message });
}
});
// POST /api/payments/full-ocr-import
router.post(
"/full-ocr-import",
async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const { rows } = req.body;
if (!rows || !Array.isArray(rows)) {
return res.status(400).json({ message: "Invalid OCR payload" });
}
const paymentIds = await paymentService.fullOcrPaymentService.importRows(
rows,
userId
);
res.status(200).json({
message: "OCR rows imported successfully",
paymentIds,
});
} catch (err) {
console.error(err);
if (err instanceof Error) {
return res.status(500).json({ message: err.message });
}
return res
.status(500)
.json({ message: "Unknown error importing OCR payments" });
}
}
);
// POST /api/payments/:claimId
router.post("/:claimId", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const claimId = parseIntOrError(req.params.claimId, "Claim ID");
const validated = insertPaymentSchema.safeParse({
...req.body,
claimId,
userId,
});
if (!validated.success) {
return res.status(400).json({
message: "Validation failed",
errors: validated.error.flatten(),
});
}
const payment = await storage.createPayment(validated.data);
res.status(201).json(payment);
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to create payment";
res.status(500).json({ message });
}
});
// PUT /api/payments/:id
router.put("/:id", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const paymentId = parseIntOrError(req.params.id, "Payment ID");
const validated = newTransactionPayloadSchema.safeParse(
req.body.data as NewTransactionPayload
);
if (!validated.success) {
return res.status(400).json({
message: "Validation failed",
errors: validated.error.flatten(),
});
}
const { serviceLineTransactions } = validated.data;
const updatedPayment = await paymentService.updatePayment(
paymentId,
serviceLineTransactions,
userId
);
res.status(200).json(updatedPayment);
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to update payment";
res.status(500).json({ message });
}
});
// PUT /api/payments/:id/pay-absolute-full-claim
router.put(
"/:id/pay-absolute-full-claim",
async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const paymentId = parseIntOrError(req.params.id, "Payment ID");
const paymentRecord = await storage.getPaymentById(paymentId);
if (!paymentRecord) {
return res.status(404).json({ message: "Payment not found" });
}
// Collect service lines from either claim or direct payment(OCR based data)
const serviceLines = paymentRecord.claim
? paymentRecord.claim.serviceLines
: paymentRecord.serviceLines;
if (!serviceLines || serviceLines.length === 0) {
return res
.status(400)
.json({ message: "No service lines available for this payment" });
}
const serviceLineTransactions = serviceLines
.filter((line) => line.totalDue.gt(0))
.map((line) => ({
serviceLineId: line.id,
paidAmount: line.totalDue.toNumber(),
adjustedAmount: 0,
method: paymentMethodOptions.CHECK,
receivedDate: new Date(),
notes: "Full claim payment",
}));
if (serviceLineTransactions.length === 0) {
return res.status(400).json({ message: "No outstanding balance" });
}
// Use updatePayment for consistency & validation
const updatedPayment = await paymentService.updatePayment(
paymentId,
serviceLineTransactions,
userId
);
res.status(200).json(updatedPayment);
} catch (err) {
console.error(err);
res.status(500).json({ message: "Failed to pay full claim" });
}
}
);
// PUT /api/payments/:id/revert-full-claim
router.put(
"/:id/revert-full-claim",
async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const paymentId = parseIntOrError(req.params.id, "Payment ID");
const paymentRecord = await storage.getPaymentById(paymentId);
if (!paymentRecord) {
return res.status(404).json({ message: "Payment not found" });
}
const serviceLines = paymentRecord.claim
? paymentRecord.claim.serviceLines
: paymentRecord.serviceLines;
if (!serviceLines || serviceLines.length === 0) {
return res
.status(400)
.json({ message: "No service lines available for this payment" });
}
// Build reversal transactions (negating whats already paid/adjusted)
const serviceLineTransactions = serviceLines
.filter((line) => line.totalPaid.gt(0) || line.totalAdjusted.gt(0))
.map((line) => ({
serviceLineId: line.id,
paidAmount: line.totalPaid.negated().toNumber(), // negative to undo
adjustedAmount: line.totalAdjusted.negated().toNumber(),
method: paymentMethodOptions.OTHER,
receivedDate: new Date(),
notes: "Reverted full claim",
}));
if (serviceLineTransactions.length === 0) {
return res.status(400).json({ message: "Nothing to revert" });
}
const updatedPayment = await paymentService.updatePayment(
paymentId,
serviceLineTransactions,
userId,
{ isReversal: true }
);
res.status(200).json(updatedPayment);
} catch (err) {
console.error(err);
res.status(500).json({ message: "Failed to revert claim payments" });
}
}
);
// PATCH /api/payments/:id/status
router.patch(
"/:id/status",
async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ message: "Unauthorized" });
}
const paymentId = parseIntOrError(req.params.id, "Payment ID");
// Parse & coerce to PaymentStatus enum
const rawStatus = PaymentStatusSchema.parse(req.body.data.status);
if (
!Object.values(paymentStatusOptions).includes(
rawStatus as PaymentStatus
)
) {
return res.status(400).json({ message: "Invalid payment status" });
}
const status = rawStatus as PaymentStatus;
// Load existing payment
const existingPayment = await storage.getPayment(paymentId);
if (!existingPayment) {
return res.status(404).json({ message: "Payment not found" });
}
// If changing to VOID and linked to a claim -> update both atomically
if (status === paymentStatusOptions.VOID && existingPayment.claimId) {
const [updatedPayment, updatedClaim] = await prisma.$transaction([
prisma.payment.update({
where: { id: paymentId },
data: { status, updatedById: userId },
}),
prisma.claim.update({
where: { id: existingPayment.claimId },
data: { status: claimStatusOptions.VOID },
}),
]);
return res.json(updatedPayment);
}
// Otherwise just update payment (use storage helper)
const updatedPayment = await storage.updatePaymentStatus(
paymentId,
{ status } as any,
userId
);
return res.json(updatedPayment);
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to update payment status";
return res.status(500).json({ message });
}
}
);
// DELETE /api/payments/:id
router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const id = parseIntOrError(req.params.id, "Payment ID");
// Check if payment exists and belongs to this user
const existingPayment = await storage.getPayment(id);
if (!existingPayment) {
return res.status(404).json({ message: "Payment not found" });
}
if (existingPayment.userId !== req.user!.id) {
return res.status(403).json({
message:
"Forbidden: Payment belongs to a different user, you can't delete this.",
});
}
await storage.deletePayment(id, userId);
res.status(200).json({ message: "Payment deleted successfully" });
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to delete payment";
res.status(500).json({ message });
}
});
export default router;

113
apps/Backend/src/routes/staffs.ts Executable file
View File

@@ -0,0 +1,113 @@
import { Router } from "express";
import type { Request, Response } from "express";
import { storage } from "../storage";
import { z } from "zod";
import { StaffUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
type Staff = z.infer<typeof StaffUncheckedCreateInputObjectSchema>;
const staffCreateSchema = StaffUncheckedCreateInputObjectSchema;
const staffUpdateSchema = (
StaffUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
).partial();
const router = Router();
router.post("/", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user!.id; // from auth middleware
const validatedData = staffCreateSchema.parse({
...req.body,
userId,
});
const newStaff = await storage.createStaff(validatedData);
res.status(200).json(newStaff);
} catch (error) {
console.error("Failed to create staff:", error);
res.status(500).send("Failed to create staff");
}
});
router.get("/", async (req: Request, res: Response): Promise<any> => {
try {
const staff = await storage.getAllStaff();
if (!staff) return res.status(404).send("Staff not found");
res.status(201).json(staff);
} catch (error) {
console.error("Failed to fetch staff:", error);
res.status(500).send("Failed to fetch staff");
}
});
router.put("/:id", async (req: Request, res: Response): Promise<any> => {
try {
const parsedStaffId = Number(req.params.id);
if (isNaN(parsedStaffId)) {
return res.status(400).send("Invalid staff ID");
}
const validatedData = staffUpdateSchema.parse(req.body);
const updatedStaff = await storage.updateStaff(
parsedStaffId,
validatedData
);
if (!updatedStaff) return res.status(404).send("Staff not found");
res.json(updatedStaff);
} catch (error) {
console.error("Failed to update staff:", error);
res.status(500).send("Failed to update staff");
}
});
const parseIdOr400 = (raw: any, label: string) => {
const n = Number(raw);
if (!Number.isFinite(n)) throw new Error(`${label} is invalid`);
return n;
};
router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user!.id;
const id = parseIdOr400(req.params.id, "Staff ID");
const parsedStaffId = Number(req.params.id);
if (isNaN(parsedStaffId)) {
return res.status(400).send("Invalid staff ID");
}
const existing = await storage.getStaff(id); // must include createdById
if (!existing) return res.status(404).json({ message: "Staff not found" });
if (existing.userId !== userId) {
return res.status(403).json({
message:
"Forbidden: Staff was created by a different user; you cannot delete it.",
});
}
const [apptCount, claimCount] = await Promise.all([
storage.countAppointmentsByStaffId(id),
storage.countClaimsByStaffId(id),
]);
if (apptCount || claimCount) {
return res.status(409).json({
message: `Cannot delete staff with linked records. Appointment of this staff : ${apptCount} and Claims ${claimCount}`,
hint: "Archive this staff, or reassign linked records, then delete.",
});
}
const deleted = await storage.deleteStaff(parsedStaffId);
if (!deleted) return res.status(404).send("Staff not found");
res.status(200).send("Staff deleted successfully");
} catch (error) {
console.error("Failed to delete staff:", error);
res.status(500).send("Failed to delete staff");
}
});
export default router;

127
apps/Backend/src/routes/users.ts Executable file
View File

@@ -0,0 +1,127 @@
import { Router } from "express";
import type { Request, Response } from "express";
import { storage } from "../storage";
import { z } from "zod";
import { UserUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
const router = Router();
// Type based on shared schema
type SelectUser = z.infer<typeof UserUncheckedCreateInputObjectSchema>;
// Zod validation
const userCreateSchema = UserUncheckedCreateInputObjectSchema;
const userUpdateSchema = (UserUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>).partial();
router.get("/", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).send("Unauthorized UserId");
const user = await storage.getUser(userId);
if (!user) return res.status(404).send("User not found");
const { password, ...safeUser } = user;
res.json(safeUser);
} catch (error) {
console.error(error);
res.status(500).send("Failed to fetch user");
}
});
// GET: User by ID
router.get("/:id", async (req: Request, res: Response): Promise<any> => {
try {
const idParam = req.params.id;
if (!idParam) return res.status(400).send("User ID is required");
const id = parseInt(idParam);
if (isNaN(id)) return res.status(400).send("Invalid user ID");
const user = await storage.getUser(id);
if (!user) return res.status(404).send("User not found");
const { password, ...safeUser } = user;
res.json(safeUser);
} catch (error) {
console.error(error);
res.status(500).send("Failed to fetch user");
}
});
// POST: Create new user
router.post("/", async (req: Request, res: Response) => {
try {
const input = userCreateSchema.parse(req.body);
const newUser = await storage.createUser(input);
const { password, ...safeUser } = newUser;
res.status(201).json(safeUser);
} catch (err) {
console.error(err);
res.status(400).json({ error: "Invalid user data", details: err });
}
});
// Function to hash password using bcrypt
async function hashPassword(password: string) {
const saltRounds = 10; // Salt rounds for bcrypt
const hashedPassword = await bcrypt.hash(password, saltRounds);
return hashedPassword;
}
// PUT: Update user
router.put("/:id", async (req: Request, res: Response):Promise<any> => {
try {
const idParam = req.params.id;
if (!idParam) return res.status(400).send("User ID is required");
const id = parseInt(idParam);
if (isNaN(id)) return res.status(400).send("Invalid user ID");
const updates = userUpdateSchema.parse(req.body);
// If password is provided and non-empty, hash it
if (updates.password && updates.password.trim() !== "") {
updates.password = await hashPassword(updates.password);
} else {
// Remove password field if empty, so it won't overwrite existing password with blank
delete updates.password;
}
const updatedUser = await storage.updateUser(id, updates);
if (!updatedUser) return res.status(404).send("User not found");
const { password, ...safeUser } = updatedUser;
res.json(safeUser);
} catch (err) {
console.error(err);
res.status(400).json({ error: "Invalid update data", details: err });
}
});
// DELETE: Delete user
router.delete("/:id", async (req: Request, res: Response):Promise<any> => {
try {
const idParam = req.params.id;
if (!idParam) return res.status(400).send("User ID is required");
const id = parseInt(idParam);
if (isNaN(id)) return res.status(400).send("Invalid user ID");
const success = await storage.deleteUser(id);
if (!success) return res.status(404).send("User not found");
res.status(204).send();
} catch (error) {
console.error(error);
res.status(500).send("Failed to delete user");
}
});
export default router;