Files
DentalManagementMH06/apps/Backend/src/routes/appointments.ts
ff 9d0cfe5dba 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>
2026-05-29 14:18:10 -04:00

507 lines
18 KiB
TypeScript
Executable File

import { Router } from "express";
import type { Request, Response } from "express";
import { storage } from "../storage";
import { z } from "zod";
import {
insertAppointmentSchema,
updateAppointmentSchema,
} from "@repo/db/types";
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 {
const appointments = await storage.getAllAppointments();
res.json(appointments);
} catch (error) {
res.status(500).json({ message: "Failed to retrieve all appointments" });
}
});
/**
* GET /api/appointments/day?date=YYYY-MM-DD
* Response: { appointments: Appointment[], patients: Patient[] }
*/
router.get("/day", async (req: Request, res: Response): Promise<any> => {
function isValidYMD(s: string) {
return /^\d{4}-\d{2}-\d{2}$/.test(s);
}
try {
const rawDate = req.query.date as string | undefined;
if (!rawDate || !isValidYMD(rawDate)) {
return res.status(400).json({ message: "Date query param is required." });
}
if (!req.user) return res.status(401).json({ message: "Unauthorized" });
// Build literal UTC day bounds from the YYYY-MM-DD query string
const start = new Date(`${rawDate}T00:00:00.000Z`);
const end = new Date(`${rawDate}T23:59:59.999Z`);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
return res.status(400).json({ message: "Invalid date format" });
}
// Call the storage method that takes a start/end range (no change to storage needed)
const appointments = await storage.getAppointmentsOnRange(start, end);
// dedupe patient ids referenced by those appointments
const patientIds = Array.from(
new Set(appointments.map((a) => a.patientId).filter(Boolean))
);
const patients = patientIds.length
? await storage.getPatientsByIds(patientIds)
: [];
// 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, 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) ?? []) : [],
}));
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" });
}
});
// Get recent appointments (paginated)
router.get("/recent", async (req: Request, res: Response) => {
try {
const limit = Math.max(1, parseInt(req.query.limit as string) || 10);
const offset = Math.max(0, parseInt(req.query.offset as string) || 0);
const all = await storage.getRecentAppointments(limit, offset);
res.json({ data: all, limit, offset });
} catch (err) {
res.status(500).json({ message: "Failed to get recent appointments" });
}
});
// Get a single appointment by ID
router.get(
"/:id",
async (req: Request, res: Response): Promise<any> => {
try {
const appointmentIdParam = req.params.id;
// Ensure that patientIdParam exists and is a valid number
if (!appointmentIdParam) {
return res.status(400).json({ message: "Appointment ID is required" });
}
const appointmentId = parseInt(appointmentIdParam);
const appointment = await storage.getAppointment(appointmentId);
if (!appointment) {
return res.status(404).json({ message: "Appointment not found" });
}
res.json(appointment);
} catch (error) {
res.status(500).json({ message: "Failed to retrieve appointment" });
}
}
);
// Get all appointments for a specific patient
router.get(
"/:patientId/appointments",
async (req: Request, res: Response): Promise<any> => {
try {
const rawPatientId = req.params.patientId;
if (!rawPatientId) {
return res.status(400).json({ message: "Patient ID is required" });
}
const patientId = parseInt(rawPatientId);
if (isNaN(patientId)) {
return res.status(400).json({ message: "Invalid patient ID" });
}
const patient = await storage.getPatient(patientId);
if (!patient)
return res.status(404).json({ message: "Patient not found" });
const appointments = await storage.getAppointmentsByPatientId(patientId);
res.json(appointments);
} catch (err) {
res.status(500).json({ message: "Failed to get patient appointments" });
}
}
);
/**
* GET /api/appointments/:id/patient
*/
router.get(
"/:id/patient",
async (req: Request, res: Response): Promise<any> => {
try {
const rawId = req.params.id;
if (!rawId) {
return res.status(400).json({ message: "Appointment ID is required" });
}
const apptId = parseInt(rawId, 10);
if (Number.isNaN(apptId) || apptId <= 0) {
return res.status(400).json({ message: "Invalid appointment ID" });
}
const patient = await storage.getPatientFromAppointmentId(apptId);
if (!patient) {
return res
.status(404)
.json({ message: "Patient not found for the given appointment" });
}
return res.json(patient);
} catch (err) {
return res
.status(500)
.json({ message: "Failed to retrieve patient for appointment" });
}
}
);
// Create a new appointment
router.post(
"/upsert",
async (req: Request, res: Response): Promise<any> => {
try {
// Validate request body
const appointmentData = insertAppointmentSchema.parse({
...req.body,
userId: req.user!.id,
});
const originalStartTime = appointmentData.startTime;
const MAX_END_TIME = "18:30";
// 1. Verify patient exists
const patient = await storage.getPatient(appointmentData.patientId);
if (!patient) {
return res.status(404).json({ message: "Patient not found" });
}
// 2. Find the next available slot for this staff member (multiple same-patient same-day bookings allowed)
let [hour, minute] = originalStartTime.split(":").map(Number);
const pad = (n: number) => n.toString().padStart(2, "0");
const STEP_MINUTES = 15;
const APPT_DURATION_MINUTES = 30;
while (`${pad(hour)}:${pad(minute)}` <= MAX_END_TIME) {
const currentStartTime = `${pad(hour)}:${pad(minute)}`;
const staffConflict = await storage.getStaffAppointmentByDateTime(
appointmentData.staffId,
appointmentData.date,
currentStartTime,
undefined
);
if (!staffConflict) {
const endMinute = minute + APPT_DURATION_MINUTES;
const endHour = hour + Math.floor(endMinute / 60);
const realEndMinute = endMinute % 60;
const currentEndTime = `${pad(endHour)}:${pad(realEndMinute)}`;
const created = await storage.createAppointment({
...appointmentData,
startTime: currentStartTime,
endTime: currentEndTime,
});
return res.status(201).json({
...created,
originalRequestedTime: originalStartTime,
finalScheduledTime: currentStartTime,
message:
originalStartTime !== currentStartTime
? `Your requested time (${originalStartTime}) was unavailable. Appointment was scheduled at ${currentStartTime}.`
: `Appointment successfully scheduled at ${currentStartTime}.`,
});
}
minute += STEP_MINUTES;
if (minute >= 60) {
hour += Math.floor(minute / 60);
minute = minute % 60;
}
}
return res.status(409).json({
message: "No available slots remaining until 6:30 PM for this staff member. Please choose another day.",
});
} catch (error) {
console.error("Error in upsert appointment:", error);
if (error instanceof z.ZodError) {
console.log(
"Validation error details:",
JSON.stringify(error.format(), null, 2)
);
return res.status(400).json({
message: "Validation error",
errors: error.format(),
});
}
res.status(500).json({
message: "Failed to upsert appointment",
error: error instanceof Error ? error.message : String(error),
});
}
}
);
// Update an existing appointment
router.put(
"/:id",
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({
...bodyWithoutTypeLocked,
userId: req.user!.id,
});
const appointmentIdParam = req.params.id;
if (!appointmentIdParam) {
return res.status(400).json({ message: "Appointment ID is required" });
}
const appointmentId = parseInt(appointmentIdParam);
// 1. Verify patient exists and belongs to user
const patient = await storage.getPatient(appointmentData.patientId);
if (!patient) {
return res.status(404).json({ message: "Patient not found" });
}
// 2. Check if appointment exists and belongs to user
const existingAppointment = await storage.getAppointment(appointmentId);
if (!existingAppointment) {
console.log("Appointment not found:", appointmentId);
return res.status(404).json({ message: "Appointment not found" });
}
// 4. Reject patientId change (not allowed)
if (
appointmentData.patientId &&
appointmentData.patientId !== existingAppointment.patientId
) {
return res
.status(400)
.json({ message: "Changing patientId is not allowed" });
}
// 5. Check for conflicting appointments (same patient OR staff at same time)
const date = appointmentData.date ?? existingAppointment.date;
const startTime =
appointmentData.startTime ?? existingAppointment.startTime;
const staffId = appointmentData.staffId ?? existingAppointment.staffId;
const patientConflict = await storage.getPatientConflictAppointment(
existingAppointment.patientId,
date,
startTime,
appointmentId
);
if (patientConflict) {
return res.status(409).json({
message: "This patient already has an appointment at this time.",
});
}
const staffConflict = await storage.getStaffConflictAppointment(
staffId,
date,
startTime,
appointmentId
);
if (staffConflict) {
return res.status(409).json({
message: "This time slot is already booked for the selected staff.",
});
}
// 6. if date gets updated, then also update the aptmnt status to unknown.
// Normalize to YYYY-MM-DD to avoid timezone problems (model uses @db.Date)
const oldYMD = new Date(existingAppointment.date)
.toISOString()
.slice(0, 10);
const newYMD = new Date(date).toISOString().slice(0, 10);
const isDateChanged = oldYMD !== newYMD;
// Only pass the fields that are safe to update; never overwrite patientId/userId via this route
const updatePayload: Record<string, unknown> = {};
if (appointmentData.staffId !== undefined) updatePayload.staffId = appointmentData.staffId;
if (appointmentData.title !== undefined) updatePayload.title = appointmentData.title;
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 (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";
// Update appointment
const updatedAppointment = await storage.updateAppointment(
appointmentId,
updatePayload as any
);
return res.json(updatedAppointment);
} catch (error) {
// Prisma error objects crash Node's util.inspect — always log as string
const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
console.error("Error updating appointment:", msg);
if (error instanceof z.ZodError) {
const fieldErrors = error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(" | ");
console.error("Zod validation in PUT /appointments:", fieldErrors);
return res.status(400).json({ message: fieldErrors });
}
return res.status(500).json({
message: msg,
});
}
}
);
// 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 {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ message: "Invalid appointment ID" });
const updated = await storage.updateAppointment(id, { movedByAi: false } as any);
return res.json(updated);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return res.status(500).json({ message: msg });
}
});
// Delete an appointment
router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
try {
const appointmentIdParam = req.params.id;
if (!appointmentIdParam) {
return res.status(400).json({ message: "Appointment ID is required" });
}
const appointmentId = parseInt(appointmentIdParam);
// Check if appointment exists and belongs to user
const existingAppointment = await storage.getAppointment(appointmentId);
if (!existingAppointment) {
return res.status(404).json({ message: "Appointment not found" });
}
if (existingAppointment.userId !== req.user!.id) {
return res.status(403).json({
message:
"Forbidden: Appointment belongs to a different user, you can't delete this.",
});
}
// Delete appointment
await storage.deleteAppointment(appointmentId);
res.status(204).send();
} catch (error) {
res.status(500).json({ message: "Failed to delete appointment" });
}
});
export default router;