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";
|
} from "@groombook/db";
|
||||||
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
||||||
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||||
|
import {
|
||||||
|
detectAndCascadeOverrun,
|
||||||
|
isOverrun,
|
||||||
|
} from "../lib/cascade.js";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
async function withRetry<T>(
|
async function withRetry<T>(
|
||||||
@@ -584,6 +588,7 @@ appointmentsRouter.patch(
|
|||||||
// (fixes #18). Also falls back to the existing staffId when staffId is
|
// (fixes #18). Also falls back to the existing staffId when staffId is
|
||||||
// omitted from the request, so rescheduling always checks conflicts (fixes #19).
|
// omitted from the request, so rescheduling always checks conflicts (fixes #19).
|
||||||
let row: typeof appointments.$inferSelect | undefined;
|
let row: typeof appointments.$inferSelect | undefined;
|
||||||
|
let originalEndTime: Date | undefined;
|
||||||
try {
|
try {
|
||||||
row = await db.transaction(async (tx) => {
|
row = await db.transaction(async (tx) => {
|
||||||
const [current] = await tx
|
const [current] = await tx
|
||||||
@@ -595,6 +600,9 @@ appointmentsRouter.patch(
|
|||||||
throw Object.assign(new Error("not found"), { statusCode: 404 });
|
throw Object.assign(new Error("not found"), { statusCode: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preserve original endTime for cascade detection after update
|
||||||
|
originalEndTime = current.endTime;
|
||||||
|
|
||||||
const start = updateFields.startTime
|
const start = updateFields.startTime
|
||||||
? new Date(updateFields.startTime)
|
? new Date(updateFields.startTime)
|
||||||
: current.startTime;
|
: current.startTime;
|
||||||
@@ -684,6 +692,29 @@ appointmentsRouter.patch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
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);
|
return c.json(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -201,3 +201,52 @@ export function buildWaitlistNotificationEmail(
|
|||||||
<p>— Groom Book</p>`,
|
<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