1184 lines
42 KiB
TypeScript
Executable File
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;
|