feat: appointment type inference, procedure codes on cards, claim attachment fixes

- Add appointment type categories matching insurance claim form (recall, filling, pedo, dentures, implant, endo, crown, perio, extraction, ortho, consultation, emergency, other)
- Auto-infer appointment type from CDT codes with priority rules (endo > implant > crown > ...)
- typeLocked flag prevents auto-overwrite when user manually sets type
- Show appointment type label and procedure codes on schedule cards
- Background sync on /day route retroactively fixes stale appointment types
- Fix PUT /api/claims/:id to save claimFiles (previously silently dropped)
- Auto-link AppointmentFile records to ClaimFile when claim is created or updated
- Fix D5750 (denture reline) CDT range to map correctly to dentures category
- Fix typeLocked Zod rejection in appointment update route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ff
2026-05-29 14:18:10 -04:00
co-authored by Claude Sonnet 4.6
parent b20dc8e976
commit 9d0cfe5dba
260 changed files with 2443 additions and 1968 deletions
+99 -2
View File
@@ -22,6 +22,54 @@ import { formatDobForAgent } from "../utils/dateUtils";
const router = Router();
// ── Appointment-type inference (mirrors frontend appointmentTypeUtils.ts) ──────
function codeToApptType(raw: string): string | null {
const c = raw.replace(/\s/g, "").toUpperCase();
if (c === "D1351" || c === "D2930" || c === "D3220") return "pedo";
if (c === "D9110") return "emergency";
if (c === "D9310") return "consultation";
if (/^D3/.test(c)) return "endo";
if (/^D6/.test(c)) return "implant";
if (/^D2[78]/.test(c)) return "crown";
if (/^D5[1-8]/.test(c)) return "dentures";
if (/^D71/.test(c)) return "extraction";
if (/^D4[3-9]/.test(c)) return "perio";
if (/^D2/.test(c)) return "filling";
if (/^D8/.test(c)) return "ortho";
if (/^D[01]/.test(c)) return "recall";
return null;
}
function inferApptTypeFromCodes(codes: string[]): string | null {
const priority = ["endo","implant","crown","pedo","dentures","extraction","perio","filling","ortho","recall","consultation","emergency"];
const scores: Record<string, number> = {};
for (const code of codes) {
const t = codeToApptType(code);
if (t) scores[t] = (scores[t] ?? 0) + 1;
}
let best: string | null = null, bestCount = 0;
for (const t of priority) {
const n = scores[t] ?? 0;
if (n > bestCount) { best = t; bestCount = n; }
}
return best;
}
/** Update appointment.type if the inferred type differs and the user has not locked it. */
async function syncAppointmentType(appointmentId: number | null | undefined, codes: string[]): Promise<void> {
if (!appointmentId || !codes.length) return;
const inferred = inferApptTypeFromCodes(codes);
if (!inferred) return;
try {
const apt = await storage.getAppointment(appointmentId);
if (apt && !(apt as any).typeLocked && apt.type !== inferred) {
await storage.updateAppointment(appointmentId, { type: inferred } as any);
}
} catch {
// Non-fatal — don't break claim creation if type sync fails
}
}
// Routes
const multerStorage = multer.memoryStorage(); // NO DISK
const upload = multer({
@@ -1015,6 +1063,27 @@ router.get("/:id", async (req: Request, res: Response): Promise<any> => {
}
});
// Links AppointmentFile records to a claim as ClaimFile records (dedup by filename).
// Called after claim create or update so files saved in the procedures form always appear.
async function autoLinkAppointmentFiles(claimId: number, appointmentId: number | null | undefined) {
if (!appointmentId) return;
const { prisma: db } = await import("@repo/db/client");
const aptFiles = await db.appointmentFile.findMany({ where: { appointmentId } });
if (!aptFiles.length) return;
const existing = await db.claimFile.findMany({ where: { claimId }, select: { filename: true } });
const existingNames = new Set(existing.map((f: any) => f.filename));
const toLink = aptFiles.filter((f: any) => f.filename && !existingNames.has(f.filename));
if (!toLink.length) return;
await db.claimFile.createMany({
data: toLink.map((f: any) => ({
claimId,
filename: f.filename,
mimeType: f.mimeType ?? "",
...(f.filePath ? { filePath: String(f.filePath) } : {}),
})),
});
}
// Create a new claim
router.post("/", async (req: Request, res: Response): Promise<any> => {
try {
@@ -1095,6 +1164,13 @@ router.post("/", async (req: Request, res: Response): Promise<any> => {
// Step 2: Create claim (with service lines)
const claim = await storage.createClaim(parsedClaim);
// Step 2b: Link any AppointmentFile records saved via procedures form
await autoLinkAppointmentFiles(claim.id, claim.appointmentId);
// Step 2c: Sync appointment type from final CDT codes
const rawCodes = lines.map((l: any) => String(l.procedureCode ?? "")).filter(Boolean);
await syncAppointmentType(claim.appointmentId, rawCodes);
// Step 3: Create payment only for real submissions (not draft saves)
const isDraft = req.query.draft === "true";
if (!isDraft) {
@@ -1170,10 +1246,28 @@ router.put("/:id", async (req: Request, res: Response): Promise<any> => {
totalDue: Number(line.totalBilled),
})),
});
// Sync appointment type from updated CDT codes
const updatedCodes = req.body.serviceLines
.map((l: InputServiceLine) => String(l.procedureCode ?? ""))
.filter(Boolean);
await syncAppointmentType(existingClaim.appointmentId, updatedCodes);
}
// Explicitly pick only scalar fields — skip serviceLines (handled above)
// and claimFiles (plain array from frontend is not Prisma nested format)
// If new claimFiles are provided, append them (don't delete existing ones)
if (Array.isArray(req.body.claimFiles) && req.body.claimFiles.length > 0) {
const { prisma: db } = await import("@repo/db/client");
await db.claimFile.createMany({
data: req.body.claimFiles.map((f: any) => ({
claimId,
filename: String(f.filename || ""),
mimeType: String(f.mimeType || f.mime || ""),
...(f.filePath ? { filePath: String(f.filePath) } : {}),
})),
});
}
// Explicitly pick only scalar fields — skip serviceLines and claimFiles (handled above)
// 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);
@@ -1197,6 +1291,9 @@ router.put("/:id", async (req: Request, res: Response): Promise<any> => {
});
const updatedClaim = await storage.updateClaim(claimId, claimData);
// Link any AppointmentFile records saved via procedures form (dedup by filename)
await autoLinkAppointmentFiles(claimId, updatedClaim.appointmentId);
// Propagate provider change to the linked payment so both stay in sync
if (req.body.npiProviderId) {
const { prisma: db } = await import("@repo/db/client");