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
parent b20dc8e976
commit 9d0cfe5dba
260 changed files with 2443 additions and 1968 deletions

View File

@@ -9,6 +9,35 @@ import {
const router = Router();
// Mirrors the same logic in claims.ts and appointmentTypeUtils.ts
function inferApptType(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 raw of codes) {
const c = raw.replace(/\s/g, "").toUpperCase();
let t: string | null = null;
if (c === "D1351" || c === "D2930" || c === "D3220") t = "pedo";
else if (c === "D9110") t = "emergency";
else if (c === "D9310") t = "consultation";
else if (/^D3/.test(c)) t = "endo";
else if (/^D6/.test(c)) t = "implant";
else if (/^D2[78]/.test(c)) t = "crown";
else if (/^D5[1-8]/.test(c)) t = "dentures";
else if (/^D71/.test(c)) t = "extraction";
else if (/^D4[3-9]/.test(c)) t = "perio";
else if (/^D2/.test(c)) t = "filling";
else if (/^D8/.test(c)) t = "ortho";
else if (/^D[01]/.test(c)) t = "recall";
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;
}
// Get all appointments
router.get("/all", async (req: Request, res: Response): Promise<any> => {
try {
@@ -58,18 +87,36 @@ router.get("/day", async (req: Request, res: Response): Promise<any> => {
// Enrich each appointment with procedure / claim status flags
const appointmentIds = appointments.map((a) => a.id).filter((id): id is number => id != null);
const [idsWithProcedures, idsWithClaimNumbers] = await Promise.all([
const [idsWithProcedures, idsWithClaimNumbers, procedureCodesByAppt] = await Promise.all([
storage.getAppointmentIdsWithProcedures(appointmentIds),
storage.getAppointmentIdsWithClaimNumbers(appointmentIds),
storage.getProcedureCodesByAppointmentIds(appointmentIds),
]);
const enrichedAppointments = appointments.map((a) => ({
...a,
hasProcedures: a.id != null && idsWithProcedures.has(a.id),
hasClaimWithNumber: a.id != null && idsWithClaimNumbers.has(a.id),
procedureCodes: a.id != null ? (procedureCodesByAppt.get(a.id) ?? []) : [],
}));
return res.json({ appointments: enrichedAppointments, patients });
res.json({ appointments: enrichedAppointments, patients });
// Background: fix any appointments whose stored type doesn't match their procedure codes.
// Runs after the response is sent so it never delays the page load.
// Skips appointments where typeLocked = true (user manually set the type).
setImmediate(async () => {
for (const a of enrichedAppointments) {
if (!a.id || !(a as any).procedureCodes?.length) continue;
if ((a as any).typeLocked) continue;
const inferred = inferApptType((a as any).procedureCodes);
if (inferred && (a as any).type !== inferred) {
try {
await storage.updateAppointment(a.id, { type: inferred } as any);
} catch { /* non-fatal */ }
}
}
});
} catch (err) {
console.error("Error in /api/appointments/day:", err);
res.status(500).json({ message: "Failed to load appointments for date" });
@@ -276,8 +323,12 @@ router.put(
async (req: Request, res: Response): Promise<any> => {
try {
// Extract typeLocked before Zod parse — the strict schema may not yet
// know about this field if the server started before prisma generate ran.
const { typeLocked: rawTypeLocked, ...bodyWithoutTypeLocked } = req.body;
const appointmentData = updateAppointmentSchema.parse({
...req.body,
...bodyWithoutTypeLocked,
userId: req.user!.id,
});
@@ -358,7 +409,8 @@ router.put(
if (appointmentData.date !== undefined) updatePayload.date = appointmentData.date;
if (appointmentData.startTime !== undefined) updatePayload.startTime = appointmentData.startTime;
if (appointmentData.endTime !== undefined) updatePayload.endTime = appointmentData.endTime;
if (appointmentData.type !== undefined) updatePayload.type = appointmentData.type;
if (appointmentData.type !== undefined) updatePayload.type = appointmentData.type;
if (rawTypeLocked !== undefined) updatePayload.typeLocked = Boolean(rawTypeLocked);
if (appointmentData.status !== undefined) updatePayload.status = appointmentData.status;
if (appointmentData.notes !== undefined) updatePayload.notes = appointmentData.notes;
if (isDateChanged) updatePayload.eligibilityStatus = "UNKNOWN";
@@ -387,6 +439,27 @@ router.put(
}
);
// Update just the appointment type (called after procedures are auto-inferred)
// Skips if typeLocked = true (user has manually set the type).
router.patch("/:id/type", async (req: Request, res: Response): Promise<any> => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ message: "Invalid appointment ID" });
const { type } = req.body;
if (typeof type !== "string" || !type.trim()) {
return res.status(400).json({ message: "type is required" });
}
const apt = await storage.getAppointment(id);
if (!apt) return res.status(404).json({ message: "Appointment not found" });
if ((apt as any).typeLocked) return res.json(apt); // honour user's manual choice
const updated = await storage.updateAppointment(id, { type } as any);
return res.json(updated);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return res.status(500).json({ message: msg });
}
});
// Manually confirm an AI-moved appointment (clears the movedByAi flag)
router.patch("/:id/confirm", async (req: Request, res: Response): Promise<any> => {
try {