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:
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -13,6 +13,7 @@ const SCHEDULE_FILES: Record<string, string> = {
|
||||
TUFTS_SCO: "procedureCodesTuftsSCO.json",
|
||||
UNITEDDH: "procedureCodesUnitedDH.json",
|
||||
UNITED_SCO: "procedureCodesUnitedDH.json",
|
||||
UNITEDSCO: "procedureCodesUnitedDH.json",
|
||||
};
|
||||
|
||||
function getSchedulePath(siteKey: string): string | null {
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface IAppointmentProceduresStorage {
|
||||
clearByAppointmentId(appointmentId: number): Promise<void>;
|
||||
getAppointmentFiles(appointmentId: number): Promise<AppointmentFileMeta[]>;
|
||||
getAppointmentIdsWithProcedures(ids: number[]): Promise<Set<number>>;
|
||||
getProcedureCodesByAppointmentIds(ids: number[]): Promise<Map<number, string[]>>;
|
||||
}
|
||||
|
||||
export const appointmentProceduresStorage: IAppointmentProceduresStorage = {
|
||||
@@ -171,6 +172,23 @@ export const appointmentProceduresStorage: IAppointmentProceduresStorage = {
|
||||
return new Set(rows.map((r: any) => r.appointmentId));
|
||||
},
|
||||
|
||||
async getProcedureCodesByAppointmentIds(ids: number[]): Promise<Map<number, string[]>> {
|
||||
if (!ids.length) return new Map();
|
||||
const rows = await db.appointmentProcedure.findMany({
|
||||
where: { appointmentId: { in: ids } },
|
||||
select: { appointmentId: true, procedureCode: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
const map = new Map<number, string[]>();
|
||||
for (const r of rows as any[]) {
|
||||
if (!r.procedureCode) continue;
|
||||
const list = map.get(r.appointmentId) ?? [];
|
||||
list.push(r.procedureCode);
|
||||
map.set(r.appointmentId, list);
|
||||
}
|
||||
return map;
|
||||
},
|
||||
|
||||
async getAppointmentFiles(appointmentId: number): Promise<AppointmentFileMeta[]> {
|
||||
const rows = await db.appointmentFile.findMany({
|
||||
where: { appointmentId },
|
||||
|
||||
Reference in New Issue
Block a user