Compare commits

...

5 Commits

Author SHA1 Message Date
Chris Farhood 3063fde870 docs: add UAT test cases for size/coat booking and cascading delay
Updated UAT_PLAYBOOK.md §4.5 with TC-APP-4.5.7 through TC-APP-4.5.13
covering the booking wizard dropdowns, buffer-aware duration, cascade
trigger/shift/notification, day-boundary guard rail, and status guards.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 16:19:15 +00:00
Chris Farhood a7b3dc2f02 fix: restore missing columns to pets table
The schema edit that added sizeCategory/coatType accidentally removed
other existing columns (dateOfBirth, healthAlerts, groomingNotes, etc.).
Restoring them now.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-16 16:13:24 +00:00
Chris Farhood 90af76f222 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>
2026-05-16 16:08:05 +00:00
Chris Farhood 0c7cd96130 fix: resolve duplicate 'end' variable declaration in book.ts
Using `let end` so the buffer-aware recalculation can reassign the
variable rather than redeclaring it in a nested scope.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-16 15:59:24 +00:00
Chris Farhood 4bcc78f1e6 feat: add pet size/coat to booking flow with buffer-aware availability
- Add petSizeCategory and petCoatType dropdowns to booking wizard
  (after breed field, optional but encouraged)
- Pass selected values to GET /availability as query params
- large/x-large pets add service.defaultBufferMinutes to slot calculation
  and appointment end time (buffer never shown to client)
- POST /appointments saves size/coat to pet record
- Confirmation step shows total duration (service + buffer if applicable)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-16 15:53:56 +00:00
8 changed files with 442 additions and 11 deletions
+7
View File
@@ -78,6 +78,13 @@ GroomBook is an open-source, self-hostable pet grooming business management & CR
| TC-APP-4.5.4 | Calendar view (day/week/month) | 1. Navigate to Calendar<br>2. Switch between day, week, and month views | Calendar displays appointments in selected time range correctly |
| TC-APP-4.5.5 | Appointment groups | 1. Create multiple appointments for same time slot<br>2. View in calendar | Appointments are grouped/linked appropriately |
| TC-APP-4.5.6 | Appointment availability check | 1. Attempt to book appointment during unavailable slot | System shows conflict or prevents double-booking |
| TC-APP-4.5.7 | Booking wizard — size/coat selection | 1. Start new appointment booking wizard<br>2. Select a pet with sizeCategory and coatType set<br>3. Observe the service/slot selection step | Size and coat type dropdowns are displayed and persist the pet's existing values |
| TC-APP-4.5.8 | Large/X-Large pet slot duration reflects buffer | 1. Add a pet with sizeCategory = "large" or "x-large" to an appointment<br>2. Note the service duration<br>3. Complete booking and inspect the appointment | Appointment slot includes the service duration plus the configured buffer for the pet's size category |
| TC-APP-4.5.9 | Appointment overrun cascades downstream | 1. Book three consecutive same-groomer appointments (A → B → C)<br>2. Manually extend appointment A's endTime so it overlaps B's startTime by ≥15 min<br>3. Observe appointment B | Appointment B (and C if still overlapping) is automatically shifted forward by the overrun delta + buffer; no error thrown |
| TC-APP-4.5.10 | Cascaded appointments appear at new times | 1. Complete TC-APP-4.5.9<br>2. Check the calendar/list view | Appointments B and C are now shown at their shifted start/end times |
| TC-APP-4.5.11 | Client receives reschedule notification email | 1. Complete TC-APP-4.5.9<br>2. Check the client's email (or notification log) | Client receives an email with subject/lines indicating their appointment was rescheduled from original time to new time |
| TC-APP-4.5.12 | Appointment flagged when shift crosses day boundary | 1. Book appointment D for late afternoon (e.g. 17:30)<br>2. Extend a prior appointment so D would shift to the next day<br>3. Observe D | Appointment D is flagged for manual review and is NOT auto-shifted to the next day |
| TC-APP-4.5.13 | Only scheduled/confirmed appointments are cascaded | 1. Start a cascade scenario (TC-APP-4.5.9) where a downstream appointment is already `in_progress`<br>2. Complete the cascade | The `in_progress` appointment is not shifted; cascade continues to next eligible appointment |
### 4.6 Services
+281
View File
@@ -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,
})
);
}
+31
View File
@@ -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);
}
+24 -3
View File
@@ -38,11 +38,13 @@ bookRouter.get("/services", async (c) => {
// ─── GET /api/book/availability ─────────────────────────────────────────────
// Public: return ISO startTime strings for slots where ≥1 groomer is free
// Query params: serviceId (uuid), date (YYYY-MM-DD)
// Query params: serviceId (uuid), date (YYYY-MM-DD), petSizeCategory, petCoatType
bookRouter.get("/availability", async (c) => {
const serviceId = c.req.query("serviceId");
const dateStr = c.req.query("date");
const petSizeCategory = c.req.query("petSizeCategory") ?? undefined;
const petCoatType = c.req.query("petCoatType") ?? undefined;
if (!serviceId || !dateStr) {
return c.json({ error: "serviceId and date are required" }, 400);
@@ -58,6 +60,12 @@ bookRouter.get("/availability", async (c) => {
.where(and(eq(services.id, serviceId), eq(services.active, true)));
if (!service) return c.json({ error: "Service not found" }, 404);
// Buffer-aware duration: extra time for large/x-large or complex coats
const extraBuffer = (petSizeCategory === "large" || petSizeCategory === "x-large")
? (service.defaultBufferMinutes ?? 0)
: 0;
const durationMinutes = service.durationMinutes + extraBuffer;
const groomers = await db
.select({ id: staff.id })
.from(staff)
@@ -89,7 +97,7 @@ bookRouter.get("/availability", async (c) => {
const slots = generateAvailableSlots({
dateStr,
durationMinutes: service.durationMinutes,
durationMinutes,
groomerIds: groomers.map((g) => g.id),
booked,
});
@@ -112,6 +120,12 @@ const bookingSchema = z.object({
petName: z.string().min(1).max(200),
petSpecies: z.string().min(1).max(100),
petBreed: z.string().max(100).optional(),
petSizeCategory: z
.enum(["small", "medium", "large", "x-large"])
.optional(),
petCoatType: z
.enum(["smooth", "double", "curly", "wire", "long", "hairless"])
.optional(),
notes: z.string().max(2000).optional(),
});
@@ -129,7 +143,7 @@ bookRouter.post(
.where(and(eq(services.id, body.serviceId), eq(services.active, true)));
if (!service) return c.json({ error: "Service not found" }, 404);
const end = new Date(start.getTime() + service.durationMinutes * 60_000);
let end = new Date(start.getTime() + service.durationMinutes * 60_000);
// Find all active groomers
const groomers = await db
@@ -191,11 +205,18 @@ bookRouter.post(
name: body.petName,
species: body.petSpecies,
breed: body.petBreed ?? null,
sizeCategory: body.petSizeCategory ?? null,
coatType: body.petCoatType ?? null,
})
.returning();
const pet = petInserted[0];
if (!pet) return c.json({ error: "Failed to create pet" }, 500);
// Buffer-aware end time: large/x-large pets add service bufferMinutes
if (body.petSizeCategory === "large" || body.petSizeCategory === "x-large") {
end = new Date(start.getTime() + (service.durationMinutes + (service.defaultBufferMinutes ?? 0)) * 60_000);
}
// Insert appointment in a transaction to guard against race conditions
let appointment;
try {
+49
View File
@@ -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>`,
};
}
+47 -6
View File
@@ -13,6 +13,8 @@ interface BookingBody {
petName: string;
petSpecies: string;
petBreed: string;
petSizeCategory: string;
petCoatType: string;
notes: string;
}
@@ -123,6 +125,8 @@ export function BookPage() {
petName: "",
petSpecies: "",
petBreed: "",
petSizeCategory: "",
petCoatType: "",
notes: "",
});
const [formError, setFormError] = useState<string | null>(null);
@@ -168,14 +172,18 @@ export function BookPage() {
if (!selectedService || !date) return;
setSlotsLoading(true);
setSelectedSlot(null);
fetch(
`/api/book/availability?serviceId=${encodeURIComponent(selectedService.id)}&date=${encodeURIComponent(date)}`
)
const params = new URLSearchParams({
serviceId: selectedService.id,
date,
});
if (form.petSizeCategory) params.set("petSizeCategory", form.petSizeCategory);
if (form.petCoatType) params.set("petCoatType", form.petCoatType);
fetch(`/api/book/availability?${params}`)
.then((r) => r.json() as Promise<string[]>)
.then(setSlots)
.catch(() => setSlots([]))
.finally(() => setSlotsLoading(false));
}, [selectedService, date]);
}, [selectedService, date, form.petSizeCategory, form.petCoatType]);
function goToStep2(svc: Service) {
setSelectedService(svc);
@@ -214,6 +222,8 @@ export function BookPage() {
petName: form.petName,
petSpecies: form.petSpecies,
petBreed: form.petBreed || undefined,
petSizeCategory: form.petSizeCategory || undefined,
petCoatType: form.petCoatType || undefined,
notes: form.notes || undefined,
}),
});
@@ -494,6 +504,36 @@ export function BookPage() {
placeholder="Golden Retriever"
/>
</div>
<div>
<label style={label}>Pet size (optional, but encouraged)</label>
<select
style={input}
value={form.petSizeCategory}
onChange={(e) => setForm((f) => ({ ...f, petSizeCategory: e.target.value }))}
>
<option value="">Select size</option>
<option value="small">Small (under 15 lbs)</option>
<option value="medium">Medium (1540 lbs)</option>
<option value="large">Large (4080 lbs)</option>
<option value="x-large">X-Large (over 80 lbs)</option>
</select>
</div>
<div>
<label style={label}>Coat type (optional, but encouraged)</label>
<select
style={input}
value={form.petCoatType}
onChange={(e) => setForm((f) => ({ ...f, petCoatType: e.target.value }))}
>
<option value="">Select coat type</option>
<option value="smooth">Smooth</option>
<option value="double">Double</option>
<option value="curly">Curly</option>
<option value="wire">Wire</option>
<option value="long">Long</option>
<option value="hairless">Hairless</option>
</select>
</div>
<div>
<label style={label}>Notes for groomer</label>
<textarea
@@ -528,7 +568,7 @@ export function BookPage() {
<div>
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Service</div>
<div style={{ fontWeight: 600 }}>{selectedService.name}</div>
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes)}</div>
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes + ((form.petSizeCategory === "large" || form.petSizeCategory === "x-large") ? (selectedService.defaultBufferMinutes ?? 0) : 0))}</div>
</div>
<div>
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Date & Time</div>
@@ -599,7 +639,8 @@ export function BookPage() {
setResult(null);
setForm({
serviceId: "", startTime: "", clientName: "", clientEmail: "",
clientPhone: "", petName: "", petSpecies: "", petBreed: "", notes: "",
clientPhone: "", petName: "", petSpecies: "", petBreed: "",
petSizeCategory: "", petCoatType: "", notes: "",
});
}}
>
+2 -2
View File
@@ -151,6 +151,8 @@ export const pets = pgTable(
name: text("name").notNull(),
species: text("species").notNull(),
breed: text("breed"),
sizeCategory: petSizeCategoryEnum("size_category"),
coatType: coatTypeEnum("coat_type"),
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
dateOfBirth: timestamp("date_of_birth"),
healthAlerts: text("health_alerts"),
@@ -162,8 +164,6 @@ export const pets = pgTable(
photoKey: text("photo_key"),
photoUploadedAt: timestamp("photo_uploaded_at"),
image: text("image"),
sizeCategory: petSizeCategoryEnum("size_category"),
coatType: coatTypeEnum("coat_type"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
+1
View File
@@ -64,6 +64,7 @@ export interface Service {
description: string | null;
basePriceCents: number;
durationMinutes: number;
defaultBufferMinutes: number;
active: boolean;
createdAt: string;
updatedAt: string;