Files
DentalManagementMH07/apps/Backend/src/routes/appointments.ts
T
GiteadandClaude Sonnet 5 a42ef5f570 fix: persist and correctly forward appointment attachments to insurance claims
- Add "surg ext" alias mapping to D7210 in the AI CDT lookup.
- Add GET/DELETE endpoints for appointment attachments, and load existing
  attachments in the appointment edit modal (they were uploaded correctly
  but never re-fetched, so they appeared to vanish on reopen).
- Fix claim-form.tsx so previously-saved appointment attachments are always
  loaded (even when a claim already exists) instead of only when creating a
  brand-new claim, preventing them from being silently wiped on save.
- Merge existing appointment attachments into every insurance submit handler
  (MassHealth, CCA, DDMA, UnitedDH, TuftsSCO) so Selenium submission always
  includes files saved earlier via the Schedule editor, not just files
  uploaded in the current claim-form session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 21:03:32 -04:00

633 lines
22 KiB
TypeScript
Executable File

import { Router } from "express";
import type { Request, Response } from "express";
import { storage } from "../storage";
import { z } from "zod";
import multer from "multer";
import {
insertAppointmentSchema,
updateAppointmentSchema,
} from "@repo/db/types";
const router = Router();
// Reuses the same memory-storage + type/size limits as claims.ts's upload-to-cloud route
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB limit per file
fileFilter: (req, file, cb) => {
const allowed = ["application/pdf", "image/jpeg", "image/png", "image/webp"];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error("Unsupported file type"));
}
},
});
// 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 ("npiProviderId" in appointmentData) updatePayload.npiProviderId = appointmentData.npiProviderId ?? null;
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 });
}
});
// List attachments for an appointment
router.get(
"/:appointmentId/files",
async (req: Request, res: Response): Promise<any> => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
const appointmentId = parseInt(req.params.appointmentId || "", 10);
if (isNaN(appointmentId)) {
return res.status(400).json({ error: "Invalid appointment ID" });
}
try {
const files = await storage.getAppointmentFiles(appointmentId);
return res.json({ error: false, data: files });
} catch (err: any) {
console.error("[appointment-files]", err);
return res.status(500).json({ error: "Failed to load attachments", message: err?.message });
}
}
);
// Add a single attachment to an appointment (incremental insert, does not touch existing files)
router.post(
"/:appointmentId/files",
upload.single("file"),
async (req: Request, res: Response): Promise<any> => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
const appointmentIdParam = req.params.appointmentId;
const appointmentId = parseInt(appointmentIdParam || "", 10);
if (isNaN(appointmentId)) {
return res.status(400).json({ error: "Invalid appointment ID" });
}
const file = req.file;
if (!file) return res.status(400).json({ error: "No file uploaded" });
try {
const appointment = await storage.getAppointment(appointmentId);
if (!appointment) {
return res.status(404).json({ error: "Appointment not found" });
}
const patient = await storage.getPatient(appointment.patientId);
if (!patient) {
return res.status(404).json({ error: "Patient not found" });
}
const patientName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`
.trim()
.replace(/[/\\?%*:|"<>]/g, "-") || "unknown";
const folder = await storage.getOrCreatePatientFolder(
req.user.id,
appointment.patientId,
patientName
);
const attachmentsFolder = await storage.getOrCreateSubfolder(
req.user.id,
(folder as any).id,
"Attachments"
);
const cloudFile = await storage.initializeFileUpload(
req.user.id,
file.originalname,
file.mimetype,
BigInt(file.size),
1,
(attachmentsFolder as any).id
);
await storage.appendFileChunk((cloudFile as any).id, 0, file.buffer);
const finalized = await storage.finalizeFileUpload((cloudFile as any).id);
const created = await storage.addAppointmentFile(appointmentId, {
filename: file.originalname,
mimeType: file.mimetype,
filePath: finalized.diskPath,
});
return res.json({ error: false, data: created });
} catch (err: any) {
console.error("[appointment-files]", err);
return res.status(500).json({ error: "Failed to upload file", message: err?.message });
}
}
);
// Delete a single attachment from an appointment
router.delete(
"/:appointmentId/files/:fileId",
async (req: Request, res: Response): Promise<any> => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
const appointmentId = parseInt(req.params.appointmentId || "", 10);
const fileId = parseInt(req.params.fileId || "", 10);
if (isNaN(appointmentId) || isNaN(fileId)) {
return res.status(400).json({ error: "Invalid appointment or file ID" });
}
try {
const deleted = await storage.deleteAppointmentFile(appointmentId, fileId);
if (!deleted) return res.status(404).json({ error: "Attachment not found" });
return res.json({ error: false });
} catch (err: any) {
console.error("[appointment-files]", err);
return res.status(500).json({ error: "Failed to delete attachment", message: err?.message });
}
}
);
// 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;