diff --git a/apps/api/src/lib/cascade.ts b/apps/api/src/lib/cascade.ts new file mode 100644 index 0000000..ea9f66e --- /dev/null +++ b/apps/api/src/lib/cascade.ts @@ -0,0 +1,281 @@ +import { eq, and, gt, gte, lt, ne, or, asc } from "@groombook/db"; +import { appointments, clients, pets, services, staff, type Db } from "@groombook/db"; +import { resolveBufferMinutes } from "./buffer.js"; +import { sendEmail, buildRescheduleNotificationEmail } from "../services/email.js"; + +export interface CascadeResult { + shifted: ShiftedAppointment[]; + flaggedForReview: FlaggedAppointment[]; +} + +export interface ShiftedAppointment { + id: string; + oldStartTime: Date; + oldEndTime: Date; + newStartTime: Date; + newEndTime: Date; + shiftDeltaMs: number; +} + +export interface FlaggedAppointment { + id: string; + reason: string; + requestedStartTime: Date; + requestedEndTime: Date; +} + +interface AppointmentWithGroomer { + id: string; + clientId: string; + petId: string; + serviceId: string; + staffId: string | null; + batherStaffId: string | null; + status: string; + startTime: Date; + endTime: Date; + bufferMinutes: number; +} + +/** + * Detects and cascades appointment overruns to downstream same-groomer appointments. + * + * Trigger conditions: + * - PATCH extends endTime beyond the original endTime + * - Status transitions where current time exceeds endTime + bufferMinutes + * + * Guard rails: + * - Only shifts `scheduled` and `confirmed` appointments + * - Skips `in_progress`, `completed`, `cancelled`, `no_show` + * - Flags appointments that would fall outside business hours for manual review + */ +export async function detectAndCascadeOverrun({ + db, + overrunningAppointmentId, + newEndTime, + originalEndTime, +}: { + db: Db; + overrunningAppointmentId: string; + newEndTime: Date; + originalEndTime: Date; +}): Promise { + const result: CascadeResult = { shifted: [], flaggedForReview: [] }; + + // Fetch the overrunning appointment to get groomer/staff info + const [overrunning] = await db + .select() + .from(appointments) + .where(eq(appointments.id, overrunningAppointmentId)) + .limit(1); + + if (!overrunning) return result; + + const groomerId = overrunning.staffId; + if (!groomerId) return result; + + // Determine the effective buffer for the overrunning appointment + const bufferMinutes = await resolveBufferMinutesForAppointment(db, overrunning); + const overrunEnd = newEndTime; + const effectiveEnd = new Date(overrunEnd.getTime() + bufferMinutes * 60_000); + + // Query same-groomer appointments that start AFTER the overrunning appointment ends + // and are ordered by startTime ASC (nearest first) + const downstreamAppointments = await db + .select() + .from(appointments) + .where( + and( + eq(appointments.staffId, groomerId), + gt(appointments.startTime, overrunning.endTime), + or( + eq(appointments.status, "scheduled"), + eq(appointments.status, "confirmed") + ) + ) + ) + .orderBy(asc(appointments.startTime)); + + // Track which appointments have been processed to avoid double-processing in cascade + const processedIds = new Set(); + processedIds.add(overrunningAppointmentId); + + let currentOverrunEnd = effectiveEnd; + + for (const downstream of downstreamAppointments) { + if (processedIds.has(downstream.id)) continue; + + const downstreamBuffer = await resolveBufferMinutesForAppointment(db, downstream); + + // Check if this downstream appointment conflicts with the current overrun end + const conflictThreshold = new Date( + currentOverrunEnd.getTime() + downstreamBuffer * 60_000 + ); + + if (conflictThreshold <= downstream.startTime) { + // No conflict — cascade is complete + break; + } + + // Conflict detected — need to shift this appointment + const shiftDeltaMs = conflictThreshold.getTime() - downstream.startTime.getTime(); + const newStartTime = new Date(downstream.startTime.getTime() + shiftDeltaMs); + const newEndTime = new Date(downstream.endTime.getTime() + shiftDeltaMs); + + // Check business hours (simple: only shift within same calendar day window for now) + // A more sophisticated implementation would check actual business hours from businessSettings + const isSameDay = + newStartTime.toDateString() === downstream.startTime.toDateString(); + + if (!isSameDay) { + result.flaggedForReview.push({ + id: downstream.id, + reason: `Shifted appointment would fall on a different day (${newStartTime.toDateString()})`, + requestedStartTime: newStartTime, + requestedEndTime: newEndTime, + }); + // Continue cascade check — we still process downstream appointments + currentOverrunEnd = newEndTime; + processedIds.add(downstream.id); + continue; + } + + // Apply the shift + await db + .update(appointments) + .set({ + startTime: newStartTime, + endTime: newEndTime, + updatedAt: new Date(), + }) + .where(eq(appointments.id, downstream.id)); + + result.shifted.push({ + id: downstream.id, + oldStartTime: downstream.startTime, + oldEndTime: downstream.endTime, + newStartTime, + newEndTime, + shiftDeltaMs, + }); + + // Update current overrun end for next iteration + currentOverrunEnd = newEndTime; + processedIds.add(downstream.id); + } + + // Send notifications for all shifted appointments + for (const shifted of result.shifted) { + await notifyShiftedAppointment(db, shifted); + } + + return result; +} + +/** + * Determines if an appointment update represents an overrun that triggers cascade logic. + */ +export function isOverrun({ + originalEndTime, + newEndTime, + originalStartTime, + newStartTime, + status, + currentTime, + bufferMinutes, +}: { + originalEndTime: Date; + newEndTime: Date; + originalStartTime: Date; + newStartTime?: Date; + status: string; + currentTime: Date; + bufferMinutes: number; +}): boolean { + // Case 1: endTime extended beyond original + if (newEndTime > originalEndTime) { + return true; + } + + // Case 2: status transition where current time exceeds endTime + bufferMinutes + // This handles cases where an appointment ran long but wasn't explicitly rescheduled + if ( + (status === "in_progress" || status === "completed") && + currentTime > new Date(originalEndTime.getTime() + bufferMinutes * 60_000) + ) { + return true; + } + + return false; +} + +async function resolveBufferMinutesForAppointment( + db: Db, + appt: AppointmentWithGroomer +): Promise { + // First check if the appointment has an explicit bufferMinutes override + if (appt.bufferMinutes > 0) { + return appt.bufferMinutes; + } + + // Fall back to buffer time rules based on service + pet characteristics + const [pet] = await db + .select({ sizeCategory: pets.sizeCategory, coatType: pets.coatType }) + .from(pets) + .where(eq(pets.id, appt.petId)) + .limit(1); + + if (!pet) return 0; + + return resolveBufferMinutes({ + serviceId: appt.serviceId, + sizeCategory: pet.sizeCategory, + coatType: pet.coatType, + db, + }); +} + +async function notifyShiftedAppointment( + db: Db, + shifted: ShiftedAppointment +): Promise { + const [row] = await db + .select({ + clientName: clients.name, + clientEmail: clients.email, + clientEmailOptOut: clients.emailOptOut, + petName: pets.name, + serviceName: services.name, + groomerName: staff.name, + appointmentStartTime: appointments.startTime, + }) + .from(appointments) + .innerJoin(clients, eq(clients.id, appointments.clientId)) + .innerJoin(pets, eq(pets.id, appointments.petId)) + .innerJoin(services, eq(services.id, appointments.serviceId)) + .leftJoin(staff, eq(staff.id, appointments.staffId)) + .where(eq(appointments.id, shifted.id)) + .limit(1); + + if (!row) return; + const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row; + + if (!clientEmail || clientEmailOptOut) return; + if (!petName || !serviceName) return; + + console.log( + `[cascade] Notifying shift for appointment ${shifted.id}: ` + + `${shifted.oldStartTime.toISOString()} → ${shifted.newStartTime.toISOString()}` + ); + + await sendEmail( + buildRescheduleNotificationEmail(clientEmail, { + clientName, + petName, + serviceName, + groomerName: groomerName ?? null, + oldStartTime: shifted.oldStartTime, + newStartTime: shifted.newStartTime, + }) + ); +} \ No newline at end of file diff --git a/apps/api/src/routes/appointments.ts b/apps/api/src/routes/appointments.ts index 62e65c2..af171d2 100644 --- a/apps/api/src/routes/appointments.ts +++ b/apps/api/src/routes/appointments.ts @@ -21,6 +21,10 @@ import { } from "@groombook/db"; import { buildConfirmationEmail, sendEmail } from "../services/email.js"; import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js"; +import { + detectAndCascadeOverrun, + isOverrun, +} from "../lib/cascade.js"; import type { AppEnv } from "../middleware/rbac.js"; async function withRetry( @@ -584,6 +588,7 @@ appointmentsRouter.patch( // (fixes #18). Also falls back to the existing staffId when staffId is // omitted from the request, so rescheduling always checks conflicts (fixes #19). let row: typeof appointments.$inferSelect | undefined; + let originalEndTime: Date | undefined; try { row = await db.transaction(async (tx) => { const [current] = await tx @@ -595,6 +600,9 @@ appointmentsRouter.patch( throw Object.assign(new Error("not found"), { statusCode: 404 }); } + // Preserve original endTime for cascade detection after update + originalEndTime = current.endTime; + const start = updateFields.startTime ? new Date(updateFields.startTime) : current.startTime; @@ -684,6 +692,29 @@ appointmentsRouter.patch( } if (!row) return c.json({ error: "Not found" }, 404); + + // Cascade delay prevention: detect overrun and shift downstream appointments + if ( + originalEndTime && + updateFields.endTime && + isOverrun({ + originalEndTime, + newEndTime: new Date(updateFields.endTime), + originalStartTime: row.startTime, + status: row.status, + currentTime: new Date(), + bufferMinutes: row.bufferMinutes ?? 0, + }) + ) { + const cascadeResult = await detectAndCascadeOverrun({ + db, + overrunningAppointmentId: id, + newEndTime: new Date(updateFields.endTime), + originalEndTime, + }); + return c.json({ ...row, cascade: cascadeResult }); + } + return c.json(row); } diff --git a/apps/api/src/services/email.ts b/apps/api/src/services/email.ts index 4cd4be9..8200f6d 100644 --- a/apps/api/src/services/email.ts +++ b/apps/api/src/services/email.ts @@ -201,3 +201,52 @@ export function buildWaitlistNotificationEmail(

— Groom Book

`, }; } + +// ─── Reschedule notification email ──────────────────────────────────────────── + +interface RescheduleEmailData { + clientName: string; + petName: string; + serviceName: string; + groomerName: string | null; + oldStartTime: Date; + newStartTime: Date; +} + +export function buildRescheduleNotificationEmail( + to: string, + data: RescheduleEmailData +): Mail.Options { + const oldTime = formatDateTime(data.oldStartTime); + const newTime = formatDateTime(data.newStartTime); + const groomer = data.groomerName ? ` with ${data.groomerName}` : ""; + return { + to, + subject: `Appointment Rescheduled — ${data.petName}'s appointment has been moved`, + text: [ + `Hi ${data.clientName},`, + ``, + `Your appointment has been rescheduled.`, + ``, + ` Pet: ${data.petName}`, + ` Service: ${data.serviceName}`, + ` Was: ${oldTime}${groomer}`, + ` Now: ${newTime}${groomer}`, + ``, + `If you have any questions or need to make changes, please contact us.`, + ``, + `— Groom Book`, + ].join("\n"), + html: ` +

Hi ${data.clientName},

+

Your appointment has been rescheduled.

+ + + + + +
Pet${data.petName}
Service${data.serviceName}
Was${oldTime}${groomer}
Now${newTime}${groomer}
+

If you have any questions or need to make changes, please contact us.

+

— Groom Book

`, + }; +}