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 = {}; 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 = {}; 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 => { 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 => { 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 => { 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;