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 fs from "fs"; import axios from "axios"; import archiver from "archiver"; import { seleniumQueue } from "../queue/queues"; 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"; import { formatDobForAgent } from "../utils/dateUtils"; 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")); } }, }); // Disk-storage uploader for claim attachments saved under uploads/patients// const attachmentDiskStorage = multer.diskStorage({ destination: (req, _file, cb) => { const patientName = String(req.body.patientName || "unknown").replace(/[/\\?%*:|"<>]/g, "-").trim(); const dir = path.join(process.cwd(), "uploads", "patients", patientName); fs.mkdirSync(dir, { recursive: true }); cb(null, dir); }, filename: (_req, file, cb) => { const ext = path.extname(file.originalname); const base = path.basename(file.originalname, ext).replace(/\s+/g, "_"); cb(null, `${Date.now()}_${base}${ext}`); }, }); const attachmentUpload = multer({ storage: attachmentDiskStorage, limits: { fileSize: 20 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { const allowed = ["application/pdf", "image/jpeg", "image/png", "image/webp"]; cb(null, allowed.includes(file.mimetype)); }, }); // POST /api/claims/upload-attachments // Saves files to uploads/patients// and returns their paths. router.post( "/upload-attachments", attachmentUpload.array("files", 10), (req: Request, res: Response): any => { const files = req.files as Express.Multer.File[]; if (!files?.length) return res.status(400).json({ error: true, message: "No files uploaded" }); const result = files.map((f) => ({ filename: f.originalname, mimeType: f.mimetype, filePath: `/uploads/patients/${path.basename(path.dirname(f.path))}/${path.basename(f.path)}`, })); return res.json({ error: false, data: result }); } ); router.post( "/mh-provider-login", async (req: Request, res: Response): Promise => { 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 => { 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).pdfs ?? []; const images = (req.files as Record).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, }; // Encode file buffers as base64 so they can be stored in Redis const filesForQueue = [...pdfs, ...images].map((f) => ({ originalname: f.originalname, bufferBase64: f.buffer.toString("base64"), mimetype: f.mimetype, })); const job = await seleniumQueue.add("claim-submit", { jobType: "claim-submit", userId: req.user.id, socketId: req.body.socketId, enrichedPayload: enrichedData, files: filesForQueue, claimId: claimData.claimId, }); return res.json({ jobId: job.id, status: "queued" }); } catch (err: any) { console.error(err); return res.status(500).json({ error: err.message || "Failed to enqueue selenium claim job", }); } } ); router.post( "/selenium/fetchpdf", async (req: Request, res: Response): Promise => { 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 = { 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 => { 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).pdfs ?? []; const images = (req.files as Record).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, }; // Create a minimal PREAUTH claim record so the preauth number can be stored and shown let preAuthClaimId: number | undefined; try { const serviceDate = claimData.serviceDate ? new Date(claimData.serviceDate) : new Date(); const dob = claimData.dateOfBirth ? new Date(claimData.dateOfBirth) : new Date("2000-01-01"); const preAuthRecord = await storage.createClaim({ patientId: Number(claimData.patientId), appointmentId: claimData.appointmentId ? Number(claimData.appointmentId) : null, userId: req.user.id, staffId: Number(claimData.staffId) || 1, patientName: claimData.patientName || "", memberId: claimData.memberId || "", dateOfBirth: dob, remarks: claimData.remarks || "", missingTeethStatus: claimData.missingTeethStatus || "No_missing", serviceDate, insuranceProvider: "MassHealth", status: "PREAUTH", } as any); preAuthClaimId = preAuthRecord.id; } catch (e: any) { console.error("[preauth] failed to create preauth record:", e?.message); } const filesForQueue = [...pdfs, ...images].map((f) => ({ originalname: f.originalname, bufferBase64: f.buffer.toString("base64"), mimetype: f.mimetype, })); const job = await seleniumQueue.add("claim-pre-auth", { jobType: "claim-pre-auth", userId: req.user.id, socketId: req.body.socketId, enrichedPayload: enrichedData, files: filesForQueue, claimId: preAuthClaimId ?? claimData.claimId, }); return res.json({ jobId: job.id, status: "queued" }); } catch (err: any) { console.error(err); return res.status(500).json({ error: err.message || "Failed to enqueue selenium pre-auth job", }); } } ); // POST /api/claims/batch-column // Query params: date=YYYY-MM-DD (required), staffIds=1,2 (required) // For each appointment in the selected staff columns: // - skip if no saved AppointmentProcedure records // - otherwise create a Claim + Payment, enqueue selenium claim-submit job router.post( "/batch-column", async (req: Request, res: Response): Promise => { const date = String(req.query.date ?? "").trim(); const staffIdsRaw = String(req.query.staffIds ?? "").trim(); if (!date) return res.status(400).json({ error: "Missing date query param" }); if (!staffIdsRaw) return res.status(400).json({ error: "Missing staffIds query param" }); if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); const staffIdFilter = new Set( staffIdsRaw.split(",").map(Number).filter((n) => Number.isFinite(n) && n > 0) ); try { const allAppointments = await storage.getAppointmentsByDateForUser(date, req.user.id); const appointments = allAppointments.filter((a) => staffIdFilter.has(Number(a.staffId))); const results: Array = []; for (const apt of appointments) { const resultItem: any = { appointmentId: apt.id, patientId: apt.patientId ?? null, processed: false, skipped: false, error: null, jobId: null, claimId: null, }; try { // Fetch active claim for this appointment (includes service lines from draft saves) const activeClaim = await storage.getActiveClaimByAppointmentId(Number(apt.id)); // Skip if claim was voided via the "Void" button in Select Procedures. if (activeClaim?.status === "VOID") { resultItem.skipped = true; resultItem.error = "Voided"; results.push(resultItem); continue; } // Skip appointments whose claim was already submitted (has claimNumber or REVIEW/APPROVED). // The "Update & Resubmit" button resets the claim to PENDING so it is picked up again. const alreadySubmitted = activeClaim && ((activeClaim.claimNumber != null && String(activeClaim.claimNumber).trim() !== "") || activeClaim.status === "REVIEW" || activeClaim.status === "APPROVED"); if (alreadySubmitted) { resultItem.skipped = true; resultItem.error = "Already submitted"; results.push(resultItem); continue; } // Build service lines: prefer draft claim's saved lines, fall back to AppointmentProcedure const serviceDate = formatDobForAgent( apt.date instanceof Date ? apt.date : new Date(apt.date as any) ) ?? date; let serviceLines: Array<{ procedureCode: string; procedureDate: string | Date; toothNumber?: string; toothSurface?: string; totalBilled: number; totalAdjusted: number; totalPaid: number; totalDue: number; }>; // Fetch AppointmentProcedure records once (used for service lines fallback + npiProviderId) const apptProcedures = await storage.getByAppointmentId(Number(apt.id)); const safeProcedureDate = new Date(serviceDate); if (activeClaim?.serviceLines?.length) { // Use saved service lines from the draft claim serviceLines = activeClaim.serviceLines .filter((sl) => sl.procedureCode) .map((sl) => ({ procedureCode: sl.procedureCode, procedureDate: safeProcedureDate, toothNumber: sl.toothNumber ?? undefined, toothSurface: sl.toothSurface ?? undefined, totalBilled: Math.max(0, Number(sl.totalBilled ?? 0) || 0), totalAdjusted: 0, totalPaid: 0, totalDue: Math.max(0, Number(sl.totalBilled ?? 0) || 0), })); } else { // Fall back to AppointmentProcedure records (saved via Select Procedures) serviceLines = apptProcedures.map((proc) => ({ procedureCode: proc.procedureCode, procedureDate: safeProcedureDate, toothNumber: proc.toothNumber ?? undefined, toothSurface: proc.toothSurface ?? undefined, totalBilled: Math.max(0, Number(proc.fee ?? 0) || 0), totalAdjusted: 0, totalPaid: 0, totalDue: Math.max(0, Number(proc.fee ?? 0) || 0), })); } if (!serviceLines.length) { resultItem.skipped = true; results.push(resultItem); continue; } // Patient const patient = apt.patientId ? await storage.getPatient(apt.patientId) : null; if (!patient) { resultItem.error = "Patient not found"; results.push(resultItem); continue; } const memberId = String(patient.insuranceId ?? "").trim(); if (!memberId) { resultItem.error = "Missing insurance ID"; results.push(resultItem); continue; } const dobStr = formatDobForAgent(patient.dateOfBirth); if (!dobStr) { resultItem.error = "Missing or invalid date of birth"; results.push(resultItem); continue; } // MassHealth credentials const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(req.user.id, "MH"); if (!credentials) { resultItem.error = "No MassHealth credentials found — check Settings"; results.push(resultItem); continue; } const npiProviders = await storage.getNpiProvidersByUser(req.user.id); const npiProvider = npiProviders[0] ?? null; // procNpiProviderId = user's explicit choice saved via "Select Procedures" form // This ALWAYS wins over any previously stored claim npiProviderId const procNpiProviderId = (apptProcedures as any[]).find((p) => p.npiProviderId)?.npiProviderId ?? null; // Priority: Select Procedures choice > existing claim > first provider const claimNpiProviderId = procNpiProviderId ?? activeClaim?.npiProviderId ?? npiProvider?.id ?? null; console.log(`[batch-column] apt=${apt.id} procNpiId=${procNpiProviderId} claimNpiId=${activeClaim?.npiProviderId} resolved=${claimNpiProviderId}`); const patientName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim(); // Reuse the existing draft claim, or create a new one let claimId: number; if (activeClaim?.id) { claimId = activeClaim.id; if (procNpiProviderId && activeClaim.npiProviderId !== procNpiProviderId) { await storage.updateClaim(claimId, { npiProviderId: procNpiProviderId }); } } else { // Validate required integer fields before sending to Prisma const safeStaffId = apt.staffId ? Number(apt.staffId) : null; if (!safeStaffId || !Number.isFinite(safeStaffId)) { resultItem.error = "Appointment has no valid staff column (staffId missing)"; results.push(resultItem); continue; } const safeNpiId = claimNpiProviderId && Number.isFinite(Number(claimNpiProviderId)) ? Number(claimNpiProviderId) : null; console.log(`[batch-column] creating claim: patientId=${patient.id} aptId=${apt.id} staffId=${safeStaffId} npiProviderId=${safeNpiId} serviceDate=${serviceDate} dobStr=${dobStr} lines=${serviceLines.length}`); const newClaim = await storage.createClaim({ patientId: Number(patient.id), appointmentId: Number(apt.id), userId: req.user.id, staffId: safeStaffId, patientName, memberId, dateOfBirth: new Date(dobStr), serviceDate: new Date(serviceDate), insuranceProvider: "MassHealth", remarks: "", missingTeethStatus: "No_missing", missingTeeth: {}, status: "PENDING", serviceLines: { create: serviceLines }, ...(safeNpiId ? { npiProviderId: safeNpiId } : {}), } as any); claimId = newClaim.id; } resultItem.claimId = claimId; // Create Payment only if one doesn't already exist (prevents duplicates on retry) const existingPayment = await storage.getPaymentsByClaimId(claimId); if (!existingPayment) { const totalBilled = serviceLines.reduce((sum, l) => sum + Number(l.totalBilled), 0); await storage.createPayment({ claimId, patientId: Number(patient.id), userId: req.user.id, totalBilled: new Decimal(totalBilled), totalPaid: new Decimal(0), totalDue: new Decimal(totalBilled), status: "PENDING", notes: "", } as any); } // Resolve NPI provider object for selenium payload let resolvedNpiProvider = npiProvider; if (claimNpiProviderId) { const saved = npiProviders.find((p) => p.id === Number(claimNpiProviderId)); if (saved) resolvedNpiProvider = saved; } // Build enriched payload for selenium const enrichedPayload: any = { patientId: Number(patient.id), appointmentId: Number(apt.id), userId: req.user.id, staffId: Number(apt.staffId), patientName, memberId, dateOfBirth: dobStr, serviceDate, insuranceProvider: "MassHealth", insuranceSiteKey: "MH", missingTeethStatus: activeClaim?.missingTeethStatus ?? "No_missing", missingTeeth: activeClaim?.missingTeeth ?? {}, remarks: activeClaim?.remarks ?? "", serviceLines, claimId, massdhpUsername: credentials.username, massdhpPassword: credentials.password, }; if (resolvedNpiProvider) { enrichedPayload.npiProvider = { npiNumber: resolvedNpiProvider.npiNumber, providerName: resolvedNpiProvider.providerName, }; } // Collect attachments: appointment-level files + claim-level files const apptFiles = await storage.getAppointmentFiles(Number(apt.id)); const claimFiles = (activeClaim as any)?.claimFiles ?? []; const allFileMeta = [ ...apptFiles, ...claimFiles, ] as Array<{ filename: string; mimeType?: string | null; filePath?: string | null }>; const filesForQueue = allFileMeta.flatMap((f) => { if (!f.filePath) return []; const absPath = path.join(process.cwd(), f.filePath); if (!fs.existsSync(absPath)) { console.warn(`[batch-column] attachment not found on disk: ${absPath}`); return []; } const bufferBase64 = fs.readFileSync(absPath).toString("base64"); return [{ originalname: f.filename, bufferBase64, mimetype: f.mimeType ?? "application/octet-stream" }]; }); // Enqueue selenium claim-submit job const job = await seleniumQueue.add("claim-submit", { jobType: "claim-submit", userId: req.user.id, enrichedPayload, files: filesForQueue, claimId, }); resultItem.processed = true; resultItem.jobId = String(job.id); } catch (aptErr: any) { console.error("[batch-column] apt error:", aptErr); resultItem.error = aptErr?.message ?? String(aptErr); } results.push(resultItem); } return res.json({ results }); } catch (err: any) { console.error("[batch-column claims] error", err); return res.status(500).json({ error: err?.message ?? "Batch claim failed" }); } } ); // GET /api/claims/batch-pdf // Query params: date=YYYY-MM-DD (required), staffIds=1,2 (required) // Returns a ZIP archive of all INSURANCE_CLAIM PdfFile records for patients // scheduled on that date in the given staff columns. router.get( "/batch-pdf", async (req: Request, res: Response): Promise => { const date = String(req.query.date ?? "").trim(); const staffIdsRaw = String(req.query.staffIds ?? "").trim(); if (!date) return res.status(400).json({ error: "Missing date query param" }); if (!staffIdsRaw) return res.status(400).json({ error: "Missing staffIds query param" }); if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); const staffIdFilter = new Set( staffIdsRaw.split(",").map(Number).filter((n) => Number.isFinite(n) && n > 0) ); try { const allAppointments = await storage.getAppointmentsByDateForUser(date, req.user.id); const appointments = allAppointments.filter((a) => staffIdFilter.has(Number(a.staffId))); type PdfEntry = { filename: string; data: Buffer }; const pdfEntries: PdfEntry[] = []; for (const apt of appointments) { const patientId = apt.patientId; if (!patientId) continue; // Claim PDFs from Selenium are stored in PdfGroup (titleKey=INSURANCE_CLAIM) → PdfFile (binary pdfData) const group = await storage.findPdfGroupByPatientTitleKey(patientId, "INSURANCE_CLAIM"); if (!group?.id) continue; const raw = await storage.getPdfFilesByGroupId(group.id); const files = (Array.isArray(raw) ? raw : (raw as any).data ?? []) as Array<{ filename: string; pdfData: unknown }>; for (const f of files) { if (!f.pdfData) continue; // Prisma Bytes → always convert to Buffer to satisfy archiver const buf = Buffer.isBuffer(f.pdfData) ? f.pdfData : Buffer.from(f.pdfData as any); if (!buf.length) continue; // Sanitize filename: strip path components, keep safe chars const safeName = (f.filename || "claim.pdf").replace(/[/\\]/g, "_").trim() || "claim.pdf"; pdfEntries.push({ filename: safeName, data: buf }); } } if (pdfEntries.length === 0) { return res.status(404).json({ error: "No claim PDFs found for the selected columns and date" }); } const zipFilename = `claims_${date}.zip`; res.setHeader("Content-Type", "application/zip"); res.setHeader("Content-Disposition", `attachment; filename="${zipFilename}"`); const archive = archiver("zip", { zlib: { level: 6 } }); // Capture archiver errors before any data is written let archiveError: Error | null = null; archive.on("error", (err) => { archiveError = err; console.error("[batch-pdf] archiver error:", err); if (!res.headersSent) { res.status(500).json({ error: `ZIP creation failed: ${err.message}` }); } }); archive.pipe(res); const seenNames = new Map(); for (const entry of pdfEntries) { if (archiveError) break; const count = seenNames.get(entry.filename) ?? 0; seenNames.set(entry.filename, count + 1); const archiveName = count === 0 ? entry.filename : `${path.basename(entry.filename, ".pdf")}_${count}.pdf`; archive.append(entry.data, { name: archiveName }); } if (!archiveError) { await archive.finalize(); } } catch (err: any) { console.error("[batch-pdf] error:", err); if (!res.headersSent) { return res.status(500).json({ error: err?.message ?? "Batch PDF download failed" }); } } } ); // GET /api/claims/by-appointment/:appointmentId // Returns the most recent active (non-cancelled/void) claim with service lines and files router.get( "/by-appointment/:appointmentId", async (req: Request, res: Response): Promise => { const aptId = parseInt(req.params.appointmentId, 10); if (isNaN(aptId)) return res.status(400).json({ error: "Invalid appointmentId" }); const claim = await storage.getActiveClaimByAppointmentId(aptId); if (!claim) return res.status(404).json({ message: "No active claim found" }); return res.json(claim); } ); // 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 => { 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 => { 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 => { 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 || ""), ...(f.filePath ? { filePath: String(f.filePath) } : {}), })), }; } // --- 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) => ({ procedureCode: String(line.procedureCode ?? ""), procedureDate: line.procedureDate, quad: line.quad ?? null, arch: line.arch ?? null, toothNumber: line.toothNumber ?? null, toothSurface: line.toothSurface ?? null, totalBilled: Number(line.totalBilled), totalAdjusted: 0, totalPaid: 0, totalDue: Number(line.totalBilled), }) ); req.body.serviceLines = { create: req.body.serviceLines }; } // Strip unknown keys so strict() doesn't reject any extra properties the frontend may send const parsedClaim = (ExtendedClaimSchema as unknown as z.ZodObject).strip().parse({ patientId: req.body.patientId, appointmentId: req.body.appointmentId, staffId: req.body.staffId, patientName: req.body.patientName, memberId: req.body.memberId, dateOfBirth: req.body.dateOfBirth, remarks: req.body.remarks ?? "", missingTeethStatus: req.body.missingTeethStatus, missingTeeth: req.body.missingTeeth, serviceDate: req.body.serviceDate, insuranceProvider: req.body.insuranceProvider, status: req.body.status, serviceLines: req.body.serviceLines, claimFiles: req.body.claimFiles, ...(req.body.npiProviderId ? { npiProviderId: Number(req.body.npiProviderId) } : {}), 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 payment only for real submissions (not draft saves) const isDraft = req.query.draft === "true"; if (!isDraft) { 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) { const firstIssue = error.issues[0]; const fieldPath = firstIssue?.path?.join(".") ?? "unknown"; const fieldMsg = firstIssue?.message ?? "invalid"; console.error("❌ Claim validation error:", error.issues); return res.status(400).json({ message: `Validation error on field "${fieldPath}": ${fieldMsg}`, errors: error.format(), }); } console.error("❌ Failed to create claim:", error); // 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 => { 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 service lines are provided, replace them (delete old, create new) if (Array.isArray(req.body.serviceLines) && req.body.serviceLines.length > 0) { const { prisma: db } = await import("@repo/db/client"); await db.serviceLine.deleteMany({ where: { claimId } }); await db.serviceLine.createMany({ data: req.body.serviceLines.map((line: InputServiceLine) => ({ claimId, procedureCode: String(line.procedureCode ?? ""), procedureDate: new Date(line.procedureDate), quad: line.quad || null, arch: line.arch || null, toothNumber: line.toothNumber || null, toothSurface: line.toothSurface || null, totalBilled: Number(line.totalBilled), totalAdjusted: 0, totalPaid: 0, totalDue: Number(line.totalBilled), })), }); } // Explicitly pick only scalar fields — skip serviceLines (handled above) // and claimFiles (plain array from frontend is not Prisma nested format) // Use req.user!.id for userId (always trust the authenticated session, not the client body) // Skip null/empty-string values for optional fields to avoid coerce.date() failures const toOptionalNum = (v: any) => (v != null && !Number.isNaN(Number(v)) ? Number(v) : undefined); const toOptionalDate = (v: any) => (v != null && String(v).trim() !== "" ? v : undefined); const claimData = (updateClaimSchema as unknown as z.ZodObject).strip().parse({ patientId: toOptionalNum(req.body.patientId), appointmentId: toOptionalNum(req.body.appointmentId), userId: req.user!.id, staffId: toOptionalNum(req.body.staffId), patientName: req.body.patientName, memberId: req.body.memberId, dateOfBirth: toOptionalDate(req.body.dateOfBirth), remarks: req.body.remarks ?? "", missingTeethStatus: req.body.missingTeethStatus, missingTeeth: req.body.missingTeeth, serviceDate: toOptionalDate(req.body.serviceDate), insuranceProvider: req.body.insuranceProvider, status: req.body.status, ...(req.body.npiProviderId ? { npiProviderId: Number(req.body.npiProviderId) } : {}), }); const updatedClaim = await storage.updateClaim(claimId, claimData); // Propagate provider change to the linked payment so both stay in sync if (req.body.npiProviderId) { const { prisma: db } = await import("@repo/db/client"); await db.payment.updateMany({ where: { claimId }, data: { npiProviderId: Number(req.body.npiProviderId) }, }); } res.json(updatedClaim); } catch (error) { if (error instanceof z.ZodError) { const firstIssue = error.issues[0]; const fieldPath = firstIssue?.path?.join(".") ?? "unknown"; const fieldMsg = firstIssue?.message ?? "invalid"; console.error("❌ Claim update validation error:", error.issues); return res.status(400).json({ message: `Validation error on field "${fieldPath}": ${fieldMsg}`, errors: error.format(), }); } console.error("❌ Failed to update claim:", error); res.status(500).json({ message: "Failed to update claim" }); } }); // Delete a claim router.delete("/:id", async (req: Request, res: Response): Promise => { 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" }); } }); // POST /api/claims/void-for-appointment // Marks the claim for an appointment as VOID so batch-column skips it permanently. // If no claim exists yet, creates a minimal placeholder VOID claim. router.post("/void-for-appointment", async (req: Request, res: Response): Promise => { try { if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); const { appointmentId } = req.body; if (!appointmentId || isNaN(Number(appointmentId))) { return res.status(400).json({ error: "Invalid appointmentId" }); } const existing = await storage.getActiveClaimByAppointmentId(Number(appointmentId)); if (existing) { await storage.updateClaim(Number(existing.id), { status: "VOID" } as any); return res.json({ voided: true, claimId: existing.id }); } // No claim yet — look up appointment + patient to create a minimal VOID placeholder const apt = await storage.getAppointment(Number(appointmentId)); if (!apt) return res.status(404).json({ error: "Appointment not found" }); const patient = apt.patientId ? await storage.getPatient(apt.patientId) : null; if (!patient) return res.status(404).json({ error: "Patient not found" }); const newClaim = await storage.createClaim({ patientId: Number(patient.id), appointmentId: Number(appointmentId), userId: req.user.id, staffId: Number(apt.staffId), patientName: `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim(), memberId: String(patient.insuranceId ?? ""), dateOfBirth: patient.dateOfBirth ? new Date(patient.dateOfBirth) : new Date(), serviceDate: apt.date instanceof Date ? apt.date : new Date(apt.date as any), insuranceProvider: "MassHealth", remarks: "", missingTeethStatus: "No_missing", missingTeeth: {}, status: "VOID", } as any); return res.json({ voided: true, claimId: newClaim.id }); } catch (err: any) { console.error("void-for-appointment error", err); return res.status(500).json({ error: err.message ?? "Server error" }); } }); // POST /api/claims/reset-for-resubmit // Resets the active claim for an appointment back to PENDING with no claimNumber, // so the batch-column will pick it up again on the next run. router.post("/reset-for-resubmit", async (req: Request, res: Response): Promise => { try { const { appointmentId } = req.body; if (!appointmentId || isNaN(Number(appointmentId))) { return res.status(400).json({ error: "Invalid appointmentId" }); } const claim = await storage.getActiveClaimByAppointmentId(Number(appointmentId)); if (!claim) { return res.json({ reset: false, message: "No existing claim found — will be created fresh on next submit" }); } await storage.updateClaim(Number(claim.id), { status: "PENDING", claimNumber: null } as any); return res.json({ reset: true, claimId: claim.id }); } catch (err: any) { console.error("reset-for-resubmit error", err); return res.status(500).json({ error: err.message ?? "Server error" }); } }); export default router;