feat: Select Procedures flow, batch-column NPI provider fix, auto PDF save
- Add 'Select Procedures' right-click option on appointment page (separate from Claims/PreAuth) - Select Procedures form saves CDT codes + NPI provider to AppointmentProcedure storage - Remove Save button from insurance claim form; Claims/PreAuth opens for insurance submission only - Claims/PreAuth auto-prefills from saved procedures including NPI provider - Batch-column: procedures npiProviderId takes priority over stale claim npiProviderId - Batch-column: auto-save PDF to patient Documents after successful submission (no socket needed) - Add npiProviderId column to AppointmentProcedure table (prisma db push) - Fix 'invalid db creation invocation': guard staffId, npiProviderId, procedureDate as Date object, totalBilled NaN guard - Add full error logging to batch-column catch block Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -71,6 +71,7 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string {
|
||||
files: data.files ?? [],
|
||||
claimId: data.claimId,
|
||||
variant: jobType === "claim-pre-auth" ? "claim-pre-auth" : "claimsubmit",
|
||||
socketId: data.socketId,
|
||||
});
|
||||
}
|
||||
if (jobType === "ddma-eligibility-check") {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Processors for "claim-submit" and "claim-pre-auth" jobs.
|
||||
* Mirrors routes/claims.ts /selenium-claim and /selenium-claim-pre-auth
|
||||
*/
|
||||
import path from "path";
|
||||
import axios from "axios";
|
||||
import { storage } from "../../storage";
|
||||
import { callPythonSync } from "./_shared";
|
||||
|
||||
@@ -11,6 +13,8 @@ export interface ClaimSubmitProcessorInput {
|
||||
claimId?: number;
|
||||
/** "claimsubmit" (default) or "claim-pre-auth" */
|
||||
variant?: "claimsubmit" | "claim-pre-auth";
|
||||
/** When set, the frontend socket listener will handle the PDF download — skip auto-save */
|
||||
socketId?: string;
|
||||
}
|
||||
|
||||
export interface ClaimSubmitProcessorResult {
|
||||
@@ -20,10 +24,40 @@ export interface ClaimSubmitProcessorResult {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/** Fetch the PDF from the selenium service and save it to cloud storage. */
|
||||
async function savePdfFromSelenium(
|
||||
pdf_url: string,
|
||||
patientId: number,
|
||||
variant: "claimsubmit" | "claim-pre-auth"
|
||||
) {
|
||||
try {
|
||||
const filename = path.basename(new URL(pdf_url).pathname);
|
||||
const seleniumPort = process.env.SELENIUM_PORT || "5002";
|
||||
const localUrl = `http://localhost:${seleniumPort}/downloads/${filename}`;
|
||||
|
||||
const resp = await axios.get(localUrl, { responseType: "arraybuffer", timeout: 30000 });
|
||||
|
||||
const groupTitleKey = variant === "claim-pre-auth" ? "INSURANCE_CLAIM_PREAUTH" : "INSURANCE_CLAIM";
|
||||
const groupTitle = variant === "claim-pre-auth" ? "Claims Preauth" : "Claims";
|
||||
|
||||
let group = await storage.findPdfGroupByPatientTitleKey(patientId, groupTitleKey);
|
||||
if (!group) {
|
||||
group = await storage.createPdfGroup(patientId, groupTitle, groupTitleKey);
|
||||
}
|
||||
|
||||
await storage.createPdfFile(group.id!, filename, resp.data);
|
||||
console.log(`[claimSubmitProcessor] PDF saved for patient ${patientId}: ${filename}`);
|
||||
} catch (err: any) {
|
||||
// Non-fatal — claim was submitted; just log the PDF failure
|
||||
console.error("[claimSubmitProcessor] failed to save PDF:", err?.message ?? err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runClaimSubmitProcessor(
|
||||
input: ClaimSubmitProcessorInput
|
||||
): Promise<ClaimSubmitProcessorResult> {
|
||||
const { enrichedPayload, files, claimId } = input;
|
||||
const variant = input.variant ?? "claimsubmit";
|
||||
|
||||
// Build the same payload shape the Python /claimsubmit endpoint expects
|
||||
const pdfs = files
|
||||
@@ -36,13 +70,12 @@ export async function runClaimSubmitProcessor(
|
||||
|
||||
const payload = { claim: enrichedPayload, pdfs, images };
|
||||
|
||||
const endpoint =
|
||||
input.variant === "claim-pre-auth" ? "/claim-pre-auth" : "/claimsubmit";
|
||||
const endpoint = variant === "claim-pre-auth" ? "/claim-pre-auth" : "/claimsubmit";
|
||||
|
||||
// 1) Call the Python service synchronously (BullMQ worker handles async)
|
||||
const result = await callPythonSync(endpoint, payload, 10 * 60 * 1000);
|
||||
|
||||
// 2) Persist claimNumber and update status to REVIEW after successful submission
|
||||
// 2) Persist claimNumber and update status to REVIEW
|
||||
if (claimId) {
|
||||
try {
|
||||
const updates: Record<string, any> = { status: "REVIEW" };
|
||||
@@ -53,5 +86,14 @@ export async function runClaimSubmitProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Auto-save PDF for batch jobs (no socketId = no frontend listener to call fetchpdf)
|
||||
if (result?.pdf_url && enrichedPayload?.patientId && !input.socketId) {
|
||||
await savePdfFromSelenium(
|
||||
result.pdf_url,
|
||||
Number(enrichedPayload.patientId),
|
||||
variant
|
||||
);
|
||||
}
|
||||
|
||||
return { ...result, claimId };
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ async function processSeleniumJob(job: Job<SeleniumJobData>) {
|
||||
files: job.data.files ?? [],
|
||||
claimId: job.data.claimId,
|
||||
variant: "claimsubmit",
|
||||
socketId,
|
||||
});
|
||||
} else if (jobType === "claim-pre-auth") {
|
||||
result = await runClaimSubmitProcessor({
|
||||
@@ -64,6 +65,7 @@ async function processSeleniumJob(job: Job<SeleniumJobData>) {
|
||||
files: job.data.files ?? [],
|
||||
claimId: job.data.claimId,
|
||||
variant: "claim-pre-auth",
|
||||
socketId,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unknown selenium jobType: ${jobType}`);
|
||||
|
||||
@@ -54,6 +54,71 @@ router.get(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* PUT /api/appointment-procedures/set-npi-provider/:appointmentId
|
||||
* Set the npiProviderId on all procedures for an appointment (lightweight update).
|
||||
*/
|
||||
router.put("/set-npi-provider/:appointmentId", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const appointmentId = Number(req.params.appointmentId);
|
||||
if (isNaN(appointmentId)) return res.status(400).json({ message: "Invalid appointmentId" });
|
||||
|
||||
const npiProviderId = req.body.npiProviderId != null ? Number(req.body.npiProviderId) : null;
|
||||
|
||||
await prisma.appointmentProcedure.updateMany({
|
||||
where: { appointmentId },
|
||||
data: { npiProviderId: npiProviderId ?? null },
|
||||
});
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
console.error("set-npi-provider error", err);
|
||||
return res.status(500).json({ message: err.message ?? "Server error" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/appointment-procedures/save-for-appointment
|
||||
* Replace all procedures for an appointment (clear + insert).
|
||||
* Body: { appointmentId, patientId, npiProviderId?, procedures: [{procedureCode, fee?, toothNumber?, toothSurface?}] }
|
||||
*/
|
||||
router.post("/save-for-appointment", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { appointmentId, patientId, npiProviderId, procedures } = req.body;
|
||||
|
||||
if (!appointmentId || isNaN(Number(appointmentId))) {
|
||||
return res.status(400).json({ message: "Invalid appointmentId" });
|
||||
}
|
||||
if (!patientId || isNaN(Number(patientId))) {
|
||||
return res.status(400).json({ message: "Invalid patientId" });
|
||||
}
|
||||
if (!Array.isArray(procedures)) {
|
||||
return res.status(400).json({ message: "procedures must be an array" });
|
||||
}
|
||||
|
||||
const filtered = (procedures as any[]).filter(
|
||||
(p) => String(p.procedureCode ?? "").trim() !== ""
|
||||
);
|
||||
|
||||
const count = await storage.saveForAppointment({
|
||||
appointmentId: Number(appointmentId),
|
||||
patientId: Number(patientId),
|
||||
npiProviderId: npiProviderId ? Number(npiProviderId) : null,
|
||||
procedures: filtered.map((p) => ({
|
||||
procedureCode: String(p.procedureCode).trim(),
|
||||
fee: p.fee != null ? Number(p.fee) : null,
|
||||
toothNumber: p.toothNumber || null,
|
||||
toothSurface: p.toothSurface || null,
|
||||
})),
|
||||
});
|
||||
|
||||
return res.json({ success: true, count });
|
||||
} catch (err: any) {
|
||||
console.error("save-for-appointment error", err);
|
||||
return res.status(500).json({ message: err.message ?? "Server error" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/appointment-procedures
|
||||
* Add single manual procedure
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { seleniumQueue } from "../queue/queues";
|
||||
import { Prisma } from "@repo/db/generated/prisma";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
updateClaimSchema,
|
||||
} from "@repo/db/types";
|
||||
import { forwardToSeleniumClaimPreAuthAgent } from "../services/seleniumInsuranceClaimPreAuthClient";
|
||||
import { formatDobForAgent } from "../utils/dateUtils";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -38,6 +40,48 @@ const upload = multer({
|
||||
},
|
||||
});
|
||||
|
||||
// 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> => {
|
||||
@@ -341,6 +385,298 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
// 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));
|
||||
|
||||
// "Already claimed" = has a real claim number OR status is REVIEW/APPROVED
|
||||
// A PENDING claim with no claimNumber is just a draft save — not yet submitted
|
||||
const alreadyClaimed =
|
||||
activeClaim &&
|
||||
((activeClaim.claimNumber != null &&
|
||||
String(activeClaim.claimNumber).trim() !== "") ||
|
||||
activeClaim.status === "REVIEW" ||
|
||||
activeClaim.status === "APPROVED");
|
||||
|
||||
if (alreadyClaimed) {
|
||||
resultItem.skipped = true;
|
||||
resultItem.error = "Already claimed";
|
||||
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;
|
||||
// Update claim's npiProviderId if the user chose a different provider via Select Procedures
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// Enqueue selenium claim-submit job
|
||||
const job = await seleniumQueue.add("claim-submit", {
|
||||
jobType: "claim-submit",
|
||||
userId: req.user.id,
|
||||
enrichedPayload,
|
||||
files: [],
|
||||
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/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 {
|
||||
@@ -435,6 +771,7 @@ router.post("/", async (req: Request, res: Response): Promise<any> => {
|
||||
create: req.body.claimFiles.map((f: any) => ({
|
||||
filename: String(f.filename),
|
||||
mimeType: String(f.mimeType || f.mime || ""),
|
||||
...(f.filePath ? { filePath: String(f.filePath) } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -452,7 +789,12 @@ router.post("/", async (req: Request, res: Response): Promise<any> => {
|
||||
if (Array.isArray(req.body.serviceLines)) {
|
||||
req.body.serviceLines = req.body.serviceLines.map(
|
||||
(line: InputServiceLine) => ({
|
||||
...line,
|
||||
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,
|
||||
@@ -462,8 +804,23 @@ router.post("/", async (req: Request, res: Response): Promise<any> => {
|
||||
req.body.serviceLines = { create: req.body.serviceLines };
|
||||
}
|
||||
|
||||
const parsedClaim = ExtendedClaimSchema.parse({
|
||||
...req.body,
|
||||
// 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,
|
||||
});
|
||||
|
||||
@@ -484,28 +841,35 @@ router.post("/", async (req: Request, res: Response): Promise<any> => {
|
||||
// Step 2: Create claim (with service lines)
|
||||
const claim = await storage.createClaim(parsedClaim);
|
||||
|
||||
// Step 3: Create empty payment
|
||||
await storage.createPayment({
|
||||
claimId: claim.id,
|
||||
patientId: claim.patientId,
|
||||
userId: req.user!.id,
|
||||
totalBilled: new Decimal(totalBilled),
|
||||
totalPaid: new Decimal(0),
|
||||
totalDue: new Decimal(totalBilled),
|
||||
status: "PENDING",
|
||||
notes: "",
|
||||
});
|
||||
// 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",
|
||||
message: `Validation error on field "${fieldPath}": ${fieldMsg}`,
|
||||
errors: error.format(),
|
||||
});
|
||||
}
|
||||
|
||||
console.error("❌ Failed to create claim:", error); // logs full error to server
|
||||
console.error("❌ Failed to create claim:", error);
|
||||
|
||||
// Send more detailed info to the client (for dev only)
|
||||
return res.status(500).json({
|
||||
@@ -533,16 +897,64 @@ router.put("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
return res.status(404).json({ message: "Claim not found" });
|
||||
}
|
||||
|
||||
const claimData = updateClaimSchema.parse(req.body);
|
||||
// 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",
|
||||
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" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,7 +13,19 @@ export interface IAppointmentProceduresStorage {
|
||||
appointment: Appointment;
|
||||
patient: Patient;
|
||||
procedures: AppointmentProcedure[];
|
||||
npiProviderId: number | null;
|
||||
} | null>;
|
||||
saveForAppointment(params: {
|
||||
appointmentId: number;
|
||||
patientId: number;
|
||||
npiProviderId: number | null;
|
||||
procedures: Array<{
|
||||
procedureCode: string;
|
||||
fee?: number | null;
|
||||
toothNumber?: string | null;
|
||||
toothSurface?: string | null;
|
||||
}>;
|
||||
}): Promise<number>;
|
||||
|
||||
createProcedure(
|
||||
data: InsertAppointmentProcedure
|
||||
@@ -52,13 +64,34 @@ export const appointmentProceduresStorage: IAppointmentProceduresStorage = {
|
||||
return null;
|
||||
}
|
||||
|
||||
const npiProviderId = appointment.procedures[0]?.npiProviderId ?? null;
|
||||
|
||||
return {
|
||||
appointment,
|
||||
patient: appointment.patient,
|
||||
procedures: appointment.procedures,
|
||||
npiProviderId,
|
||||
};
|
||||
},
|
||||
|
||||
async saveForAppointment({ appointmentId, patientId, npiProviderId, procedures }) {
|
||||
await db.appointmentProcedure.deleteMany({ where: { appointmentId } });
|
||||
if (!procedures.length) return 0;
|
||||
const result = await db.appointmentProcedure.createMany({
|
||||
data: procedures.map((p) => ({
|
||||
appointmentId,
|
||||
patientId,
|
||||
npiProviderId: npiProviderId ?? null,
|
||||
procedureCode: p.procedureCode,
|
||||
fee: p.fee != null ? p.fee : null,
|
||||
toothNumber: p.toothNumber || null,
|
||||
toothSurface: p.toothSurface || null,
|
||||
source: "MANUAL" as const,
|
||||
})),
|
||||
});
|
||||
return result.count;
|
||||
},
|
||||
|
||||
async createProcedure(
|
||||
data: InsertAppointmentProcedure
|
||||
): Promise<AppointmentProcedure> {
|
||||
|
||||
@@ -22,6 +22,11 @@ export interface IStorage {
|
||||
appointment: UpdateAppointment
|
||||
): Promise<Appointment>;
|
||||
deleteAppointment(id: number): Promise<void>;
|
||||
getPatientAppointmentByDateAndStaff(
|
||||
patientId: number,
|
||||
date: Date,
|
||||
staffId: number
|
||||
): Promise<Appointment | undefined>;
|
||||
getPatientAppointmentByDateTime(
|
||||
patientId: number,
|
||||
date: Date,
|
||||
@@ -127,6 +132,19 @@ export const appointmentsStorage: IStorage = {
|
||||
}
|
||||
},
|
||||
|
||||
async getPatientAppointmentByDateAndStaff(
|
||||
patientId: number,
|
||||
date: Date,
|
||||
staffId: number
|
||||
): Promise<Appointment | undefined> {
|
||||
return (
|
||||
(await db.appointment.findFirst({
|
||||
where: { patientId, date, staffId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
})) ?? undefined
|
||||
);
|
||||
},
|
||||
|
||||
async getPatientAppointmentByDateTime(
|
||||
patientId: number,
|
||||
date: Date,
|
||||
|
||||
Reference in New Issue
Block a user