Files
DentalManagementMH05/apps/Backend/src/routes/claims.ts

1184 lines
42 KiB
TypeScript
Executable File

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/<name>/
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/<patientName>/ 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<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,
};
// 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<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 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: 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<any> => {
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<any> = [];
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<any> => {
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<string, number>();
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<any> => {
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<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 || ""),
...(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<any>).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<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 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<any>).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);
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<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" });
}
});
// 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<any> => {
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<any> => {
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;