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:
Gitead
2026-04-27 00:25:24 -04:00
parent a279a3e7c1
commit 3e899376c3
838 changed files with 28488 additions and 773 deletions

View File

@@ -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") {

View File

@@ -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 };
}

View File

@@ -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}`);

View File

@@ -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

View File

@@ -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" });
}
});

View File

@@ -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> {

View File

@@ -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,