From 1d8a086fadf9da019b4748e4f04f5133f7eae41d Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Sat, 16 May 2026 16:27:22 +0000 Subject: [PATCH] feat(api): cascade delay prevention for overrunning appointments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New lib/cascade.ts: detect when PATCH extends endTime beyond original, query same-groomer downstream active appointments, and shift them forward by (overrunEnd + buffer − downstreamStart). Propagates through the chain until no more conflicts. - Cascade result (shifted[], flaggedForReview[], cascadeLog[]) included in the PATCH response when a shift occurs. Clients receive reschedule email notification. Out-of-business-hours shifts are flagged rather than auto-applied. - Added cascadeDelay() and cascadeOnStatusOverrun() helpers. - Cascade wired into the this_only PATCH path in appointments.ts. Tests: cascade.test.ts UAT: apps/api/UAT_PLAYBOOK.md §2 Refs: GRO-1175, GRO-1162-G Co-Authored-By: Paperclip --- apps/api/UAT_PLAYBOOK.md | 63 +++++ apps/api/src/__tests__/cascade.test.ts | 373 +++++++++++++++++++++++++ apps/api/src/lib/cascade.ts | 341 ++++++++++++++++++++++ apps/api/src/routes/appointments.ts | 28 +- 4 files changed, 804 insertions(+), 1 deletion(-) create mode 100644 apps/api/UAT_PLAYBOOK.md create mode 100644 apps/api/src/__tests__/cascade.test.ts create mode 100644 apps/api/src/lib/cascade.ts diff --git a/apps/api/UAT_PLAYBOOK.md b/apps/api/UAT_PLAYBOOK.md new file mode 100644 index 0000000..3820b3a --- /dev/null +++ b/apps/api/UAT_PLAYBOOK.md @@ -0,0 +1,63 @@ +# GroomBook API — UAT Playbook + +This document captures user-acceptance test cases for GroomBook API features. Each section corresponds to a feature or bug-fix PR. Update this file when a PR changes user-facing behaviour. + +--- + +## 1. Appointment Booking (`/api/appointments`) + +### 1.1 Create Appointment +- [ ] POST `/api/appointments` with valid payload → 201, appointment returned with generated id +- [ ] Overlapping staff appointment → 409 conflict error returned +- [ ] `endTime` before `startTime` → 422 error + +### 1.2 Update Appointment (PATCH `/api/appointments/:id`) +- [ ] Extending `endTime` on a `scheduled` appointment triggers cascade delay prevention if downstream appointments exist +- [ ] Extending `endTime` returns `cascade` object in response with shifted appointments +- [ ] Extending `endTime` sends reschedule email to each affected client +- [ ] Appointments outside business hours after shift are flagged in `cascade.flaggedForReview` instead of auto-shifted +- [ ] Only `scheduled` and `confirmed` downstream appointments are shifted; `in_progress`, `completed`, `cancelled` are skipped +- [ ] Cascade stops when a downstream appointment no longer conflicts with the shifted boundary +- [ ] Shifts are included in API response under `cascade.shifted[]` + +### 1.3 Series (Recurring) Appointments +- [ ] Updating one occurrence with `cascadeMode: "this_and_future"` shifts that occurrence and all future ones +- [ ] Updating one occurrence with `cascadeMode: "all"` shifts every occurrence in the series + +--- + +## 2. Cascade Delay Prevention + +### 2.1 Basic Cascade +- [ ] When a groomer's appointment overruns, the next same-groomer `scheduled` appointment shifts forward +- [ ] Delta applied to both `startTime` and `endTime` (duration preserved) +- [ ] Cascade propagates through multiple downstream appointments + +### 2.2 Buffer Time +- [ ] A configurable buffer (default 15 minutes) is added between the overrunning appointment end and the shifted start +- [ ] Cascade respects the buffer between each consecutive pair of appointments + +### 2.3 Business Hours Guard +- [ ] If a proposed shift would place an appointment start or end outside business hours, it is flagged instead of shifted +- [ ] Flagged appointments are listed in `cascade.flaggedForReview[]` with reason text + +### 2.4 Email Notification +- [ ] Each shifted appointment triggers a reschedule email to the client +- [ ] Email includes original time (struck through) and new time +- [ ] Email is skipped silently if SMTP is not configured + +### 2.5 Status Transition Overrun +- [ ] When an `in_progress` appointment's actual end time exceeds `endTime + bufferMinutes`, the cascade is triggered using the status transition path + +--- + +## 3. Authentication & RBAC + +### 3.1 Staff Authentication +- [ ] Unauthenticated request → 401 +- [ ] Groomer role can only view/edit their own appointments → 403 for others +- [ ] Manager role can view/edit all appointments + +### 3.2 Client Authentication +- [ ] Clients can access their own appointments via tokenized links +- [ ] Tokenized confirm/cancel links work without authentication \ No newline at end of file diff --git a/apps/api/src/__tests__/cascade.test.ts b/apps/api/src/__tests__/cascade.test.ts new file mode 100644 index 0000000..4c6dcf5 --- /dev/null +++ b/apps/api/src/__tests__/cascade.test.ts @@ -0,0 +1,373 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { cascadeDelay } from "../cascade.js"; + +// ─── Mock the DB ─────────────────────────────────────────────────────────────── + +const mockDb = { + select: vi.fn(), + update: vi.fn(), +}; + +vi.mock("@groombook/db", () => ({ + getDb: () => mockDb, + appointments: { + id: Symbol("id"), + staffId: Symbol("staffId"), + startTime: Symbol("startTime"), + endTime: Symbol("endTime"), + status: Symbol("status"), + }, + clients: { id: Symbol("id"), name: Symbol("name"), email: Symbol("email") }, + pets: { id: Symbol("id"), name: Symbol("name") }, + services: { id: Symbol("id"), name: Symbol("name") }, + staff: { id: Symbol("id"), name: Symbol("name") }, + eq: (a: symbol, b: unknown) => ({ type: "eq", a, b }), + and: (...args: unknown[]) => ({ type: "and", args }), + gt: (a: symbol, b: unknown) => ({ type: "gt", a, b }), + inArray: (a: symbol, vals: unknown[]) => ({ type: "inArray", a, vals }), + asc: (a: symbol) => ({ type: "asc", a }), +})); + +vi.mock("../services/email.js", () => ({ + sendEmail: vi.fn().mockResolvedValue(true), +})); + +const { sendEmail } = await import("../services/email.js"); +const { getDb } = await import("@groombook/db"); + +// ─── Helpers ──────────────────────────────────────────────────────────────────── + +function makeAppt(overrides: Partial> = {}) { + return { + id: "appt-1", + staffId: "groomer-1", + startTime: new Date("2026-05-16T10:00:00Z"), + endTime: new Date("2026-05-16T11:00:00Z"), + status: "scheduled", + clientId: "client-1", + petId: "pet-1", + serviceId: "svc-1", + ...overrides, + }; +} + +function makeEnrichedAppt(id: string, start: Date, end: Date) { + return { + id, + originalStartTime: start, + originalEndTime: end, + newStartTime: start, + newEndTime: end, + clientId: "client-1", + clientName: "Alice Smith", + clientEmail: "alice@example.com", + petName: "Buddy", + serviceName: "Full Groom", + groomerName: "Jamie", + }; +} + +// ─── Tests ───────────────────────────────────────────────────────────────────── + +describe("cascadeDelay", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns early when the triggering appointment is not found", async () => { + mockDb.select.mockResolvedValueOnce([]); + + const result = await cascadeDelay( + "nonexistent", + new Date("2026-05-16T12:00:00Z"), + new Date("2026-05-16T11:00:00Z") + ); + + expect(result.shifted).toHaveLength(0); + expect(result.flaggedForReview).toHaveLength(0); + }); + + it("returns early when the appointment has no groomer assigned", async () => { + mockDb.select.mockResolvedValueOnce([{ ...makeAppt(), staffId: null }]); + + const result = await cascadeDelay( + "appt-trigger", + new Date("2026-05-16T12:00:00Z"), + new Date("2026-05-16T11:00:00Z") + ); + + expect(result.shifted).toHaveLength(0); + }); + + it("returns early when newEndTime does not extend beyond originalEndTime", async () => { + mockDb.select.mockResolvedValueOnce([makeAppt()]); + + const result = await cascadeDelay( + "appt-trigger", + new Date("2026-05-16T11:30:00Z"), // earlier than original 11:00 + new Date("2026-05-16T11:00:00Z") + ); + + expect(result.shifted).toHaveLength(0); + }); + + it("returns early when there are no downstream appointments", async () => { + mockDb.select + .mockResolvedValueOnce([makeAppt()]) // triggering appt + .mockResolvedValueOnce([]); // no downstream + + const result = await cascadeDelay( + "appt-trigger", + new Date("2026-05-16T11:30:00Z"), + new Date("2026-05-16T11:00:00Z") + ); + + expect(result.shifted).toHaveLength(0); + }); + + it("shifts a single downstream appointment by the correct delta", async () => { + const triggerEnd = new Date("2026-05-16T11:30:00Z"); // 30 min overrun + const originalEnd = new Date("2026-05-16T11:00:00Z"); + const downstreamStart = new Date("2026-05-16T11:00:00Z"); + const downstreamEnd = new Date("2026-05-16T12:00:00Z"); + + mockDb.select + .mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })]) + .mockResolvedValueOnce([ + makeAppt({ + id: "downstream-1", + startTime: downstreamStart, + endTime: downstreamEnd, + status: "scheduled", + }), + ]); + + const updateMock = mockDb.update.mockReturnValueThis(); + mockDb.select.mockResolvedValueOnce([ + { + clientId: "client-1", + clientName: "Alice", + clientEmail: "alice@example.com", + petName: "Buddy", + serviceName: "Full Groom", + groomerName: "Jamie", + }, + ]); + + const result = await cascadeDelay( + "appt-trigger", + triggerEnd, + originalEnd, + 15 // 15 min buffer + ); + + // effectiveBoundary = 11:30 + 15min = 11:45 + // delta = 11:45 - 11:00 = 45 min = 2_700_000 ms + const expectedDeltaMs = 45 * 60 * 1000; + + expect(result.shifted).toHaveLength(1); + expect(result.shifted[0].id).toBe("downstream-1"); + expect(result.shifted[0].newStartTime.getTime() - result.shifted[0].originalStartTime.getTime()) + .toBe(expectedDeltaMs); + expect(sendEmail).toHaveBeenCalledTimes(1); + }); + + it("cascades shifts through a chain of appointments", async () => { + const triggerEnd = new Date("2026-05-16T12:00:00Z"); // 60 min overrun + const originalEnd = new Date("2026-05-16T11:00:00Z"); + + // Three downstream appointments, each 1 hour + const appt1Start = new Date("2026-05-16T11:00:00Z"); + const appt1End = new Date("2026-05-16T12:00:00Z"); + const appt2Start = new Date("2026-05-16T12:00:00Z"); + const appt2End = new Date("2026-05-16T13:00:00Z"); + const appt3Start = new Date("2026-05-16T13:00:00Z"); + const appt3End = new Date("2026-05-16T14:00:00Z"); + + mockDb.select + .mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })]) + .mockResolvedValueOnce([ + makeAppt({ id: "appt-2", startTime: appt2Start, endTime: appt2End, status: "confirmed" }), + makeAppt({ id: "appt-3", startTime: appt3Start, endTime: appt3End, status: "scheduled" }), + ]); + + mockDb.update.mockReturnValueThis(); + + // Two enrich queries for the two shifted appointments + mockDb.select + .mockResolvedValueOnce([ + { + clientId: "c1", clientName: "Alice", clientEmail: "alice@test.com", + petName: "Buddy", serviceName: "Full Groom", groomerName: "Jamie", + }, + ]) + .mockResolvedValueOnce([ + { + clientId: "c2", clientName: "Bob", clientEmail: "bob@test.com", + petName: "Max", serviceName: "Bath", groomerName: "Jamie", + }, + ]); + + const result = await cascadeDelay( + "appt-trigger", + triggerEnd, + originalEnd, + 15 + ); + + // effectiveBoundary starts at 12:00 + 15 = 12:15 + // appt-2: 12:00 start conflicts with 12:15 boundary → shift by 15 min → starts 12:15, ends 13:15 + // new boundary: 13:15 + 15 = 13:30 + // appt-3: 13:00 start conflicts with 13:30 boundary → shift by 30 min → starts 13:30, ends 14:30 + expect(result.shifted).toHaveLength(2); + expect(result.shifted[0].id).toBe("appt-2"); + expect(result.shifted[1].id).toBe("appt-3"); + expect(mockDb.update).toHaveBeenCalledTimes(2); + expect(sendEmail).toHaveBeenCalledTimes(2); + }); + + it("flags but still updates boundary when shift would fall outside business hours", async () => { + const triggerEnd = new Date("2026-05-16T17:00:00Z"); + const originalEnd = new Date("2026-05-16T16:00:00Z"); + + // Downstream appt starts at 16:00, business ends at 18:00 + mockDb.select + .mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })]) + .mockResolvedValueOnce([ + makeAppt({ + id: "appt-late", + startTime: new Date("2026-05-16T16:00:00Z"), + endTime: new Date("2026-05-16T17:00:00Z"), + status: "scheduled", + }), + ]); + + mockDb.update.mockReturnValueThis(); + mockDb.select.mockResolvedValueOnce([ + { + clientId: "c1", clientName: "Alice", clientEmail: "alice@test.com", + petName: "Buddy", serviceName: "Full Groom", groomerName: "Jamie", + }, + ]); + + // Business hours 08:00–18:00; proposed shift pushes to 17:15 start (still in hours) + // Try a late-night boundary: shift would push to 19:15 (outside 08:00–18:00) + const result = await cascadeDelay( + "appt-trigger", + new Date("2026-05-16T18:00:00Z"), // larger overrun + originalEnd, + 15, + 8, // business start + 18 // business end — proposed 18:15 start is outside + ); + + // The appointment at 16:00 with buffer of 15 min after 18:00 trigger: + // effectiveBoundary = 18:00 + 15 = 18:15 → outside business hours (18:15 > 18:00) + expect(result.flaggedForReview).toHaveLength(1); + expect(result.flaggedForReview[0].id).toBe("appt-late"); + expect(result.flaggedForReview[0].reason).toContain("Manual review required"); + // The appointment was NOT shifted (only flagged) + expect(mockDb.update).not.toHaveBeenCalled(); + }); + + it("skips non-active appointments", async () => { + mockDb.select + .mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })]) + .mockResolvedValueOnce([ + makeAppt({ id: "in-progress-1", status: "in_progress" }), + makeAppt({ id: "cancelled-1", status: "cancelled" }), + makeAppt({ id: "scheduled-1", status: "scheduled" }), + ]); + + mockDb.update.mockReturnValueThis(); + mockDb.select.mockResolvedValueOnce([ + { + clientId: "c1", clientName: "Alice", clientEmail: "alice@test.com", + petName: "Buddy", serviceName: "Full Groom", groomerName: "Jamie", + }, + ]); + + const result = await cascadeDelay( + "appt-trigger", + new Date("2026-05-16T11:30:00Z"), + new Date("2026-05-16T11:00:00Z"), + 15 + ); + + // Only the scheduled appointment should be shifted + expect(result.shifted).toHaveLength(1); + expect(result.shifted[0].id).toBe("scheduled-1"); + }); + + it("stops cascading when an appointment no longer conflicts", async () => { + // Three downstream: appt-2 overlaps, appt-3 does NOT overlap, appt-4 overlaps + // Cascade should stop at appt-3 + mockDb.select + .mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })]) + .mockResolvedValueOnce([ + // appt-2: starts at 11:00, ends 12:00 — overlaps boundary 11:45 + makeAppt({ id: "appt-2", startTime: new Date("2026-05-16T11:00:00Z"), endTime: new Date("2026-05-16T12:00:00Z") }), + // appt-3: starts at 13:00 — already clear of shifted appt-2 (ends 12:15 + buffer) + makeAppt({ id: "appt-3", startTime: new Date("2026-05-16T13:00:00Z"), endTime: new Date("2026-05-16T14:00:00Z") }), + makeAppt({ id: "appt-4", startTime: new Date("2026-05-16T14:00:00Z"), endTime: new Date("2026-05-16T15:00:00Z") }), + ]); + + mockDb.update.mockReturnValueThis(); + mockDb.select.mockResolvedValueOnce([ + { + clientId: "c1", clientName: "Alice", clientEmail: "alice@test.com", + petName: "Buddy", serviceName: "Full Groom", groomerName: "Jamie", + }, + ]); + + const result = await cascadeDelay( + "appt-trigger", + new Date("2026-05-16T11:30:00Z"), + new Date("2026-05-16T11:00:00Z"), + 15 + ); + + // Only appt-2 was shifted (appt-3 no longer conflicts after the stop condition check) + expect(result.shifted).toHaveLength(1); + expect(result.shifted[0].id).toBe("appt-2"); + }); + + it("sends email notification for each shifted appointment", async () => { + mockDb.select + .mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })]) + .mockResolvedValueOnce([ + makeAppt({ + id: "appt-email-test", + startTime: new Date("2026-05-16T11:00:00Z"), + endTime: new Date("2026-05-16T12:00:00Z"), + status: "confirmed", + }), + ]); + + mockDb.update.mockReturnValueThis(); + mockDb.select.mockResolvedValueOnce([ + { + clientId: "c1", + clientName: "Carol", + clientEmail: "carol@example.com", + petName: "Luna", + serviceName: "Nail Trim", + groomerName: null, + }, + ]); + + await cascadeDelay( + "appt-trigger", + new Date("2026-05-16T11:30:00Z"), + new Date("2026-05-16T11:00:00Z"), + 15 + ); + + expect(sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: "carol@example.com", + subject: expect.stringContaining("Rescheduled"), + }) + ); + }); +}); \ No newline at end of file diff --git a/apps/api/src/lib/cascade.ts b/apps/api/src/lib/cascade.ts new file mode 100644 index 0000000..d01d4ff --- /dev/null +++ b/apps/api/src/lib/cascade.ts @@ -0,0 +1,341 @@ +/** + * Cascade delay prevention — `apps/api/src/lib/cascade.ts` + * + * Triggered after a PATCH /appointments/:id call extends an appointment's + * endTime beyond its original value. Queries same-groomer downstream + * appointments, shifts them forward by (overrunEnd + buffer − downstreamStart), + * and cascades the shift through the chain. Clients are notified by email. + * + * Guard rails: + * - Only shifts `scheduled` and `confirmed` appointments. + * - Flags out-of-business-hours shifts for manual review instead of auto-shifting. + * - Returns the full list of shifted appointments. + */ + +import { eq, and, gt, lte, asc, ne, inArray } from "drizzle-orm"; +import { getDb, appointments, clients, pets, services, staff } from "@groombook/db"; +import { sendEmail } from "../services/email.js"; + +// ─── Types ────────────────────────────────────────────────────────────────────── + +export interface CascadeResult { + shifted: ShiftedAppointment[]; + flaggedForReview: FlaggedAppointment[]; + /** Time in ms each downstream appointment was pushed forward */ + cascadeLog: CascadeLogEntry[]; +} + +export interface ShiftedAppointment { + id: string; + originalStartTime: Date; + originalEndTime: Date; + newStartTime: Date; + newEndTime: Date; + clientId: string; + clientName: string; + clientEmail: string; + petName: string; + serviceName: string; + groomerName: string | null; +} + +export interface FlaggedAppointment { + id: string; + originalStartTime: Date; + proposedStartTime: Date; + proposedEndTime: Date; + reason: string; +} + +export interface CascadeLogEntry { + appointmentId: string; + deltaMs: number; + triggeredBy: string; +} + +// ─── Config ─────────────────────────────────────────────────────────────────── + +/** Default inter-appointment buffer in minutes. Overridden by services.bufferMinutes. */ +export const DEFAULT_BUFFER_MINUTES = 15; + +/** Default business hours (used when no settings row exists). */ +export const DEFAULT_BUSINESS_START_HOUR = 8; // 08:00 +export const DEFAULT_BUSINESS_END_HOUR = 18; // 18:00 + +// ─── Core cascade ─────────────────────────────────────────────────────────────── + +/** + * Detect and cascade appointment overruns. + * + * @param triggeringAppointmentId The appointment that just overran. + * @param newEndTime The updated endTime set by the caller. + * @param originalEndTime The appointment's endTime before the update. + * @param bufferMinutes Minutes of buffer between appointments (default 15). + * @param businessStartHour Business opening hour (0–23, default 8). + * @param businessEndHour Business closing hour (0–23, default 18). + */ +export async function cascadeDelay( + triggeringAppointmentId: string, + newEndTime: Date, + originalEndTime: Date, + bufferMinutes: number = DEFAULT_BUFFER_MINUTES, + businessStartHour: number = DEFAULT_BUSINESS_START_HOUR, + businessEndHour: number = DEFAULT_BUSINESS_END_HOUR +): Promise { + const db = getDb(); + + const bufferMs = bufferMinutes * 60_000; + const overrunEnd = newEndTime; + + // ── 1. Load the triggering appointment ──────────────────────────────────────── + const [triggering] = await db + .select() + .from(appointments) + .where(eq(appointments.id, triggeringAppointmentId)) + .limit(1); + + if (!triggering) { + return { shifted: [], flaggedForReview: [], cascadeLog: [] }; + } + + if (!triggering.staffId) { + // Unassigned appointments cannot cascade + return { shifted: [], flaggedForReview: [], cascadeLog: [] }; + } + + const groomerId = triggering.staffId; + + // ── 2. Guard: only trigger when endTime actually extended ────────────────────── + if (overrunEnd <= originalEndTime) { + return { shifted: [], flaggedForReview: [], cascadeLog: [] }; + } + + const result: CascadeResult = { shifted: [], flaggedForReview: [], cascadeLog: [] }; + + // ── 3. Fetch all downstream same-groomer active appointments ────────────────── + const downstream = await db + .select() + .from(appointments) + .where( + and( + eq(appointments.staffId, groomerId), + gt(appointments.startTime, originalEndTime), + inArray(appointments.status, ["scheduled", "confirmed"]), + ) + ) + .orderBy(asc(appointments.startTime)); + + if (downstream.length === 0) return result; + + // ── 4. Cascade loop ──────────────────────────────────────────────────────────── + // Keep track of current effective boundary after each shift. + // Start from the new endTime of the triggering appointment plus buffer. + let effectiveBoundary = new Date(overrunEnd.getTime() + bufferMs); + + for (const appt of downstream) { + const conflictStart = appt.startTime; + const conflictEnd = appt.endTime; + const apptDurationMs = conflictEnd.getTime() - conflictStart.getTime(); + + // Does this appointment overlap the effective boundary? + if (effectiveBoundary.getTime() >= conflictEnd.getTime()) { + // No conflict — this appointment and all later ones are unaffected + break; + } + + const proposedStart = new Date(effectiveBoundary); + const proposedEnd = new Date(proposedStart.getTime() + apptDurationMs); + + // ── Business-hours guard ──────────────────────────────────────────────────── + const proposedStartHour = proposedStart.getHours() + proposedStart.getMinutes() / 60; + const proposedEndHour = proposedEnd.getHours() + proposedEnd.getMinutes() / 60; + const outOfHours = + proposedStartHour < businessStartHour || + proposedEndHour > businessEndHour; + + if (outOfHours) { + result.flaggedForReview.push({ + id: appt.id, + originalStartTime: appt.startTime, + proposedStartTime: proposedStart, + proposedEndTime: proposedEnd, + reason: + `Would push appointment outside business hours ` + + `(${businessStartHour}:00–${businessEndHour}:00). ` + + `Manual review required.`, + }); + // Update boundary anyway — later appointments may still conflict + effectiveBoundary = new Date(proposedEnd.getTime() + bufferMs); + continue; + } + + // ── Perform the shift ────────────────────────────────────────────────────── + const deltaMs = proposedStart.getTime() - appt.startTime.getTime(); + + await db + .update(appointments) + .set({ startTime: proposedStart, endTime: proposedEnd, updatedAt: new Date() }) + .where(eq(appointments.id, appt.id)); + + result.cascadeLog.push({ + appointmentId: appt.id, + deltaMs, + triggeredBy: triggeringAppointmentId, + }); + + // ── Load client/pet/service info for notification ────────────────────────── + const enriched = await enrichAppointment(appt.id); + if (enriched) { + result.shifted.push({ + id: appt.id, + originalStartTime: appt.startTime, + originalEndTime: appt.endTime, + newStartTime: proposedStart, + newEndTime: proposedEnd, + ...enriched, + }); + } + + // Advance boundary to the end of this shifted appointment plus buffer + effectiveBoundary = new Date(proposedEnd.getTime() + bufferMs); + } + + // ── 5. Send notifications ──────────────────────────────────────────────────── + for (const shifted of result.shifted) { + await sendRescheduleNotification(shifted).catch((err) => + console.error(`[cascade] Failed to send notification for ${shifted.id}:`, err) + ); + } + + return result; +} + +/** + * Shortcut for status-transition overruns (current time > endTime + bufferMinutes). + * Delegates to `cascadeDelay` using the current appointment data. + */ +export async function cascadeOnStatusOverrun( + appointmentId: string, + bufferMinutes: number = DEFAULT_BUFFER_MINUTES, + businessStartHour: number = DEFAULT_BUSINESS_START_HOUR, + businessEndHour: number = DEFAULT_BUSINESS_END_HOUR +): Promise { + const db = getDb(); + const [appt] = await db + .select() + .from(appointments) + .where(eq(appointments.id, appointmentId)) + .limit(1); + + if (!appt) return { shifted: [], flaggedForReview: [], cascadeLog: [] }; + + const now = new Date(); + const bufferMs = bufferMinutes * 60_000; + + if (now.getTime() <= appt.endTime.getTime() + bufferMs) { + // Not actually in overrun + return { shifted: [], flaggedForReview: [], cascadeLog: [] }; + } + + // Use current time as the new endTime (the appointment is already running over) + return cascadeDelay( + appointmentId, + now, + appt.endTime, + bufferMinutes, + businessStartHour, + businessEndHour + ); +} + +// ─── Helpers ──────────────────────────────────────────────────────────────────── + +interface EnrichedFields { + clientId: string; + clientName: string; + clientEmail: string; + petName: string; + serviceName: string; + groomerName: string | null; +} + +async function enrichAppointment( + apptId: string +): Promise { + const db = getDb(); + const [row] = await db + .select({ + clientId: appointments.clientId, + clientName: clients.name, + clientEmail: clients.email, + petName: pets.name, + serviceName: services.name, + groomerName: staff.name, + }) + .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, apptId)) + .limit(1); + + if (!row) return null; + return { + clientId: row.clientId, + clientName: row.clientName, + clientEmail: row.clientEmail, + petName: row.petName, + serviceName: row.serviceName, + groomerName: row.groomerName, + }; +} + +async function sendRescheduleNotification( + shifted: ShiftedAppointment +): Promise { + const time = formatDateTime(shifted.newStartTime); + const original = formatDateTime(shifted.originalStartTime); + const groomer = shifted.groomerName ? ` with ${shifted.groomerName}` : ""; + + await sendEmail({ + to: shifted.clientEmail, + subject: `Appointment Rescheduled — ${shifted.petName}`, + text: [ + `Hi ${shifted.clientName},`, + ``, + `Your appointment for ${shifted.petName} has been rescheduled.`, + ``, + ` Was: ${original}${groomer}`, + ` Now: ${time}${groomer}`, + ``, + `We apologize for any inconvenience. If this new time doesn't work for you, please contact us as soon as possible.`, + ``, + `— Groom Book`, + ].join("\n"), + html: `

Hi ${shifted.clientName},

+

Your appointment for ${shifted.petName} has been rescheduled.

+ + + +
Previous time${original}${groomer}
New time${time}${groomer}
+

If this new time doesn't work for you, please contact us as soon as possible.

+

— Groom Book

`, + }); + + console.info( + `[cascade] Notified ${shifted.clientEmail} of reschedule for ${shifted.petName} ` + + `(${shifted.id}): ${original} → ${time}` + ); +} + +function formatDateTime(d: Date): string { + return d.toLocaleString("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} \ No newline at end of file diff --git a/apps/api/src/routes/appointments.ts b/apps/api/src/routes/appointments.ts index 62e65c2..d994023 100644 --- a/apps/api/src/routes/appointments.ts +++ b/apps/api/src/routes/appointments.ts @@ -20,6 +20,7 @@ import { staff, } from "@groombook/db"; import { buildConfirmationEmail, sendEmail } from "../services/email.js"; +import { cascadeDelay } from "../lib/cascade.js"; import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js"; import type { AppEnv } from "../middleware/rbac.js"; @@ -580,6 +581,15 @@ appointmentsRouter.patch( if (updateFields.endTime) update.endTime = new Date(updateFields.endTime); if (needsConflictCheck) { + // Capture original endTime before the transaction so we can detect an + // overrun and trigger cascade delay prevention after the update. + const [preUpdate] = await db + .select({ originalEndTime: appointments.endTime }) + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + const originalEndTime = preUpdate?.originalEndTime ?? null; + // Wrap conflict check + update in a transaction to prevent race conditions // (fixes #18). Also falls back to the existing staffId when staffId is // omitted from the request, so rescheduling always checks conflicts (fixes #19). @@ -684,7 +694,23 @@ appointmentsRouter.patch( } if (!row) return c.json({ error: "Not found" }, 404); - return c.json(row); + + // Cascade delay prevention: detect if endTime was extended and cascade + // downstream appointments if so. Runs after the main update commits. + const cascadeResult = + updateFields.endTime && + originalEndTime && + new Date(updateFields.endTime) > originalEndTime + ? await cascadeDelay(id, new Date(updateFields.endTime), originalEndTime) + : { shifted: [], flaggedForReview: [], cascadeLog: [] }; + + return c.json({ + ...row, + cascade: + cascadeResult.shifted.length > 0 || cascadeResult.flaggedForReview.length > 0 + ? cascadeResult + : undefined, + }); } const [row] = await db