- Add movedByAi boolean column to Appointment table (default false) - reschedule-graph: set movedByAi=true when AI moves an appointment - PATCH /api/appointments/:id/confirm endpoint clears the movedByAi flag - Schedule grid: show teal AI badge on appointment cards where movedByAi=true - Right-click context menu: 'Manually Confirmed' removes the AI badge via the confirm endpoint Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
464 lines
16 KiB
TypeScript
Executable File
464 lines
16 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();
|
|
|
|
// 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] = await Promise.all([
|
|
storage.getAppointmentIdsWithProcedures(appointmentIds),
|
|
storage.getAppointmentIdsWithClaimNumbers(appointmentIds),
|
|
]);
|
|
|
|
const enrichedAppointments = appointments.map((a) => ({
|
|
...a,
|
|
hasProcedures: a.id != null && idsWithProcedures.has(a.id),
|
|
hasClaimWithNumber: a.id != null && idsWithClaimNumbers.has(a.id),
|
|
}));
|
|
|
|
return res.json({ appointments: enrichedAppointments, patients });
|
|
} 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 and belongs to user
|
|
const patient = await storage.getPatient(appointmentData.patientId);
|
|
if (!patient) {
|
|
return res.status(404).json({ message: "Patient not found" });
|
|
}
|
|
|
|
// 2. One patient per column per day: find existing appointment for this patient in the same staff column today
|
|
const existingPatientAppointment = await storage.getPatientAppointmentByDateAndStaff(
|
|
appointmentData.patientId,
|
|
appointmentData.date,
|
|
appointmentData.staffId
|
|
);
|
|
|
|
// 3. Attempt to find the next available slot
|
|
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)}`;
|
|
|
|
// Check staff conflict at this time (exclude the patient's existing appointment so it can move)
|
|
const staffConflict = await storage.getStaffAppointmentByDateTime(
|
|
appointmentData.staffId,
|
|
appointmentData.date,
|
|
currentStartTime,
|
|
existingPatientAppointment?.id
|
|
);
|
|
|
|
if (!staffConflict) {
|
|
const endMinute = minute + APPT_DURATION_MINUTES;
|
|
let endHour = hour + Math.floor(endMinute / 60);
|
|
let realEndMinute = endMinute % 60;
|
|
|
|
const currentEndTime = `${pad(endHour)}:${pad(realEndMinute)}`;
|
|
|
|
const payload = {
|
|
...appointmentData,
|
|
startTime: currentStartTime,
|
|
endTime: currentEndTime,
|
|
};
|
|
|
|
if (existingPatientAppointment?.id !== undefined) {
|
|
// Replace the existing appointment in-place (preserves linked claims/procedures)
|
|
const updated = await storage.updateAppointment(
|
|
existingPatientAppointment.id,
|
|
payload
|
|
);
|
|
return res.status(200).json({
|
|
...updated,
|
|
originalRequestedTime: originalStartTime,
|
|
finalScheduledTime: currentStartTime,
|
|
message:
|
|
originalStartTime !== currentStartTime
|
|
? `Your requested time (${originalStartTime}) was unavailable. Appointment was updated to ${currentStartTime}.`
|
|
: `Appointment successfully updated at ${currentStartTime}.`,
|
|
});
|
|
}
|
|
|
|
const created = await storage.createAppointment(payload);
|
|
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}.`,
|
|
});
|
|
}
|
|
|
|
// Move to next STEP_MINUTES slot
|
|
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. 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 {
|
|
const appointmentData = updateAppointmentSchema.parse({
|
|
...req.body,
|
|
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 (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,
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// 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;
|