feat(appointments): cascading delay prevention for appointment overruns
When a PATCH /appointments/:id extends endTime beyond the original, detect and automatically shift downstream same-groomer appointments by the overrun delta plus buffer. Only affects scheduled/confirmed appointments; appointments that would shift outside business hours are flagged for manual review. Clients receive email notification of rescheduled times. GRO-1175: GRO-1162-G Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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<CascadeResult> {
|
||||
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<string>();
|
||||
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<number> {
|
||||
// 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<void> {
|
||||
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,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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<T>(
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -201,3 +201,52 @@ export function buildWaitlistNotificationEmail(
|
||||
<p>— Groom Book</p>`,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 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: `
|
||||
<p>Hi ${data.clientName},</p>
|
||||
<p>Your appointment has been <strong>rescheduled</strong>.</p>
|
||||
<table style="border-collapse:collapse;margin:1em 0">
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Pet</td><td>${data.petName}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Service</td><td>${data.serviceName}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#ef4444">Was</td><td style="text-decoration:line-through;color:#ef4444">${oldTime}${groomer}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#10b981">Now</td><td style="color:#10b981">${newTime}${groomer}</td></tr>
|
||||
</table>
|
||||
<p>If you have any questions or need to make changes, please contact us.</p>
|
||||
<p>— Groom Book</p>`,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user