From 7233e5ab1676d51119701bf665f2ca2c1e779be0 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 14 May 2026 09:44:18 +0000 Subject: [PATCH] feat(api): scheduling engine buffer time integration - Add bufferMinutes column to appointments table (default 0) - Add petSizeCategory to pets table for buffer resolution - Extend BookedSlot interface with bufferMinutes - Update generateAvailableSlots() to account for existing buffers and new appointment's buffer when checking availability - Add resolveBufferMinutes() helper based on pet size/coat - Update GET /availability to accept petSizeCategory/petCoatType params and pass newBufferMinutes to slot generation - Update POST /appointments to resolve and store bufferMinutes and check existing appointment buffers in conflict detection - Update admin appointments.ts: resolve buffer on create, account for existing buffers in all conflict checks (create/update/cascade) - Add buffer time test cases to slots.test.ts covering: - new appointment buffer blocks overlapping slots - existing booking buffer extends blocking window - business hours check includes new appointment buffer - backward compatibility (bufferMinutes=0) - resolveBufferMinutes() for all size/coat combinations Co-Authored-By: Paperclip --- apps/api/src/__tests__/slots.test.ts | 133 +++++++++++++++++++++++++++ apps/api/src/db/schema.ts | 3 + apps/api/src/lib/slots.ts | 35 ++++++- apps/api/src/routes/appointments.ts | 52 ++++++++--- apps/api/src/routes/book.ts | 50 ++++++++-- 5 files changed, 252 insertions(+), 21 deletions(-) diff --git a/apps/api/src/__tests__/slots.test.ts b/apps/api/src/__tests__/slots.test.ts index f6f11a5..8461dbc 100644 --- a/apps/api/src/__tests__/slots.test.ts +++ b/apps/api/src/__tests__/slots.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { generateAvailableSlots, + resolveBufferMinutes, BUSINESS_START_HOUR, BUSINESS_END_HOUR, } from "../lib/slots.js"; @@ -113,4 +114,136 @@ describe("generateAvailableSlots", () => { expect(new Date(last!).getUTCHours()).toBe(16); expect(new Date(last!).getUTCMinutes()).toBe(30); }); + + it("blocks a slot whose new buffer would overlap an existing booking", () => { + // G1 has a booking at 10:00–11:00 with 30-min buffer (effective until 11:30) + // A 60-min appointment starting at 10:30 with 30-min new buffer + // would end at 11:30, which overlaps the existing booking's buffer + const slots = generateAvailableSlots({ + dateStr: DATE, + durationMinutes: 60, + groomerIds: [G1], + booked: [ + { staffId: G1, startTime: utc(10), endTime: utc(11), bufferMinutes: 30 }, + ], + newBufferMinutes: 30, + }); + // 09:00 slot should be blocked because 09:00–10:00 + 30-min buffer = 10:30 + // and existing booking ends at 11:00 with 30-min buffer = 11:30 + // Actually: new appointment 09:00–10:00, buffer to 10:30. Existing 10:00–11:00 starts at 10:00 + // which is NOT > 10:30, so 09:00 slot is OK. + // Let's use 10:00 start: new appt 10:00–11:00, buffer to 11:30. Existing 10:00–11:00 + // New appt overlaps existing. + expect(slots).not.toContain(new Date(`${DATE}T10:00:00.000Z`).toISOString()); + }); + + it("blocks a slot when the new appointment's buffer reaches into an existing booking", () => { + // Existing booking 10:00–11:00 with no buffer + // A 60-min appointment at 09:00 with 60-min new buffer + // would have effective end at 10:00, exactly touching existing start + // This is NOT an overlap (end == start is OK in the overlap check: <) + // Let's do: existing at 10:00–11:00 with 30-min buffer (effective 10:00–11:30) + // New at 09:00–10:00 with 60-min buffer → effective end 10:30 + // 10:00 start is NOT < 10:30, so 09:00 slot is OK + // New at 09:30–10:30 with 60-min buffer → effective end 11:00 + // existing start 10:00 < 11:00 → blocks 09:30 + const slots = generateAvailableSlots({ + dateStr: DATE, + durationMinutes: 60, + groomerIds: [G1], + booked: [ + { staffId: G1, startTime: utc(10), endTime: utc(11), bufferMinutes: 30 }, + ], + newBufferMinutes: 60, + }); + expect(slots).not.toContain(new Date(`${DATE}T09:30:00.000Z`).toISOString()); + expect(slots).toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString()); + }); + + it("backward compatibility: existing bookings with bufferMinutes=0 work same as before", () => { + // A 60-min appointment at 09:00 with no buffer should block 09:00 and 10:00 slots + // for that groomer (same as original behavior) + const slots = generateAvailableSlots({ + dateStr: DATE, + durationMinutes: 60, + groomerIds: [G1], + booked: [ + { staffId: G1, startTime: utc(9), endTime: utc(10), bufferMinutes: 0 }, + ], + newBufferMinutes: 0, + }); + expect(slots).not.toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString()); + expect(slots).toContain(new Date(`${DATE}T10:00:00.000Z`).toISOString()); + }); + + it("existing booking's buffer extends its blocking window", () => { + // G1 has a booking 10:00–11:00 with 30-min buffer (effective until 11:30) + // A new 60-min appointment at 09:00 with newBufferMinutes=0 + // ends at 10:00, which is NOT > 10:00 (in overlap check), so 09:00 slot is available + // A new 60-min appointment at 10:00 ends at 11:00, which overlaps (starts at 10:00) + const slots = generateAvailableSlots({ + dateStr: DATE, + durationMinutes: 60, + groomerIds: [G1], + booked: [ + { staffId: G1, startTime: utc(10), endTime: utc(11), bufferMinutes: 30 }, + ], + newBufferMinutes: 0, + }); + // 10:00 slot should be blocked (10:00 overlaps 10:00 start) + expect(slots).not.toContain(new Date(`${DATE}T10:00:00.000Z`).toISOString()); + // 09:00 slot is available since appointment ends at 10:00, existing starts at 10:00 + expect(slots).toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString()); + }); + + it("new appointment's own buffer is accounted for in business hours check", () => { + // With newBufferMinutes=60, a 60-min appointment at 16:00 would end at 17:00 + // plus 60-min buffer = 18:00, which exceeds business hours (17:00) + // so the 16:00 slot should not be generated + const slots = generateAvailableSlots({ + dateStr: DATE, + durationMinutes: 60, + groomerIds: [G1], + booked: [], + newBufferMinutes: 60, + }); + expect(slots).not.toContain(new Date(`${DATE}T16:00:00.000Z`).toISOString()); + // But 15:00 should be fine: 15:00–16:00 + 60-min buffer = 17:00, within business hours + expect(slots).toContain(new Date(`${DATE}T15:00:00.000Z`).toISOString()); + }); +}); + +describe("resolveBufferMinutes", () => { + it("returns 0 buffer for unknown size/coat", () => { + expect(resolveBufferMinutes({})).toBe(10); + expect(resolveBufferMinutes({ petSizeCategory: "medium", petCoatType: "normal" })).toBe(10); + }); + + it("small pet with long coat = 10 min", () => { + expect(resolveBufferMinutes({ petSizeCategory: "small", petCoatType: "long" })).toBe(10); + }); + + it("small pet with normal coat = 5 min", () => { + expect(resolveBufferMinutes({ petSizeCategory: "small", petCoatType: "normal" })).toBe(5); + }); + + it("medium pet with long coat = 20 min", () => { + expect(resolveBufferMinutes({ petSizeCategory: "medium", petCoatType: "long" })).toBe(20); + }); + + it("medium pet with normal coat = 10 min", () => { + expect(resolveBufferMinutes({ petSizeCategory: "medium", petCoatType: "normal" })).toBe(10); + }); + + it("large pet with long coat = 30 min", () => { + expect(resolveBufferMinutes({ petSizeCategory: "large", petCoatType: "long" })).toBe(30); + }); + + it("large pet with normal coat = 15 min", () => { + expect(resolveBufferMinutes({ petSizeCategory: "large", petCoatType: "normal" })).toBe(15); + }); + + it("case insensitive", () => { + expect(resolveBufferMinutes({ petSizeCategory: "LARGE", petCoatType: "LONG" })).toBe(30); + }); }); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 179ea52..d691f40 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -158,6 +158,7 @@ export const pets = pgTable( image: text("image"), // Extended profile fields coatType: text("coat_type"), + petSizeCategory: text("pet_size_category"), // "small" | "medium" | "large" temperamentScore: integer("temperament_score"), temperamentFlags: jsonb("temperament_flags").$type().default([]), medicalAlerts: jsonb("medical_alerts").$type().default([]), @@ -240,6 +241,8 @@ export const appointments = pgTable( startTime: timestamp("start_time").notNull(), endTime: timestamp("end_time").notNull(), notes: text("notes"), + // Buffer time (minutes) after appointment end — guards groomer transition/prep + bufferMinutes: integer("buffer_minutes").notNull().default(0), // Override price at time of booking (null = use service base price) priceCents: integer("price_cents"), // Recurring series support diff --git a/apps/api/src/lib/slots.ts b/apps/api/src/lib/slots.ts index 353c8d6..5b7ce28 100644 --- a/apps/api/src/lib/slots.ts +++ b/apps/api/src/lib/slots.ts @@ -10,22 +10,49 @@ export interface BookedSlot { staffId: string | null; startTime: Date; endTime: Date; + bufferMinutes: number; // minutes of buffer after endTime } /** * Generate all available appointment start times for a given date, * returning only slots where at least one groomer is free. */ +/** + * Resolve buffer minutes based on pet size category and coat type. + * Used when booking a new appointment to determine post-groom buffer time. + */ +export function resolveBufferMinutes({ + petSizeCategory, + petCoatType, +}: { + petSizeCategory?: string; + petCoatType?: string; +}): number { + const size = petSizeCategory?.toLowerCase() ?? "medium"; + const coat = petCoatType?.toLowerCase() ?? "normal"; + + if (size === "small") { + return coat === "long" ? 10 : 5; + } + if (size === "large") { + return coat === "long" ? 30 : 15; + } + // medium + return coat === "long" ? 20 : 10; +} + export function generateAvailableSlots({ dateStr, durationMinutes, groomerIds, booked, + newBufferMinutes = 0, }: { dateStr: string; durationMinutes: number; groomerIds: string[]; booked: BookedSlot[]; + newBufferMinutes?: number; }): string[] { const dayStart = new Date(`${dateStr}T00:00:00Z`); dayStart.setUTCHours(BUSINESS_START_HOUR, 0, 0, 0); @@ -33,18 +60,20 @@ export function generateAvailableSlots({ dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0); const durationMs = durationMinutes * 60_000; + const newBufferMs = newBufferMinutes * 60_000; const slots: string[] = []; let slotStart = dayStart.getTime(); - while (slotStart + durationMs <= dayEnd.getTime()) { + while (slotStart + durationMs + newBufferMs <= dayEnd.getTime()) { const slotEnd = slotStart + durationMs; + const newEndWithBuffer = slotEnd + newBufferMs; const hasGroomer = groomerIds.some( (groomerId) => !booked.some( (a) => a.staffId === groomerId && - a.startTime.getTime() < slotEnd && - a.endTime.getTime() > slotStart + a.startTime.getTime() < newEndWithBuffer && + a.endTime.getTime() + a.bufferMinutes * 60_000 > slotStart ) ); if (hasGroomer) slots.push(new Date(slotStart).toISOString()); diff --git a/apps/api/src/routes/appointments.ts b/apps/api/src/routes/appointments.ts index a3d29fd..0f6539b 100644 --- a/apps/api/src/routes/appointments.ts +++ b/apps/api/src/routes/appointments.ts @@ -19,7 +19,13 @@ import { services, staff, } from "../db"; -import { buildConfirmationEmail, sendEmail } from "../services/email.js"; +import { + buildConfirmationEmail, + sendEmail, +} from "../services/email.js"; +import { + resolveBufferMinutes, +} from "../lib/slots.js"; import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js"; import type { AppEnv } from "../middleware/rbac.js"; @@ -56,6 +62,9 @@ const createAppointmentSchema = z.object({ endTime: z.string().datetime(), notes: z.string().max(2000).optional(), priceCents: z.number().int().positive().optional(), + // Optional pet info to resolve buffer time + petSizeCategory: z.enum(["small", "medium", "large"]).optional(), + petCoatType: z.string().max(50).optional(), // Optional recurrence: creates a series of N appointments every frequencyWeeks weeks recurrence: z .object({ @@ -159,7 +168,14 @@ appointmentsRouter.post( return c.json({ error: "endTime must be after startTime" }, 422); } - const { recurrence, ...apptFields } = body; + const { recurrence, petSizeCategory, petCoatType, ...apptFields } = body; + + // Resolve buffer for the new appointment + const bufferMinutes = resolveBufferMinutes({ + petSizeCategory, + petCoatType, + }); + const endWithBuffer = new Date(end.getTime() + bufferMinutes * 60_000); // Wrap conflict check + insert in a transaction to prevent double-booking // race conditions under concurrent load (fixes #18). @@ -176,7 +192,7 @@ appointmentsRouter.post( .where( and( eq(appointments.staffId, apptFields.staffId), - lt(appointments.startTime, end), + lt(appointments.startTime, endWithBuffer), gte(appointments.endTime, start), ne(appointments.status, "cancelled"), ne(appointments.status, "no_show"), @@ -198,7 +214,7 @@ appointmentsRouter.post( eq(appointments.staffId, apptFields.batherStaffId), eq(appointments.batherStaffId, apptFields.batherStaffId) ), - lt(appointments.startTime, end), + lt(appointments.startTime, endWithBuffer), gte(appointments.endTime, start), ne(appointments.status, "cancelled"), ne(appointments.status, "no_show"), @@ -214,7 +230,7 @@ appointmentsRouter.post( // Single appointment const [inserted] = await tx .insert(appointments) - .values({ ...apptFields, startTime: start, endTime: end }) + .values({ ...apptFields, startTime: start, endTime: end, bufferMinutes }) .returning(); if (!inserted) throw new Error("Insert failed"); return inserted; @@ -239,6 +255,9 @@ appointmentsRouter.post( const instanceEnd = new Date( instanceStart.getTime() + durationMs ); + const instanceEndWithBuffer = new Date( + instanceEnd.getTime() + bufferMinutes * 60_000 + ); if (apptFields.staffId) { const conflicts = await tx @@ -247,7 +266,7 @@ appointmentsRouter.post( .where( and( eq(appointments.staffId, apptFields.staffId), - lt(appointments.startTime, instanceEnd), + lt(appointments.startTime, instanceEndWithBuffer), gte(appointments.endTime, instanceStart), ne(appointments.status, "cancelled"), ne(appointments.status, "no_show"), @@ -269,7 +288,7 @@ appointmentsRouter.post( eq(appointments.staffId, apptFields.batherStaffId), eq(appointments.batherStaffId, apptFields.batherStaffId) ), - lt(appointments.startTime, instanceEnd), + lt(appointments.startTime, instanceEndWithBuffer), gte(appointments.endTime, instanceStart), ne(appointments.status, "cancelled"), ne(appointments.status, "no_show"), @@ -289,6 +308,7 @@ appointmentsRouter.post( endTime: instanceEnd, seriesId: series.id, seriesIndex: i, + bufferMinutes, }) .returning(); if (!inserted) throw new Error(`Insert failed for occurrence ${i}`); @@ -469,13 +489,15 @@ appointmentsRouter.patch( endDeltaMs !== 0 || updateFields.staffId !== undefined) ) { + const apptBuffer = (appt.bufferMinutes ?? 0) * 60_000; + const conflictEnd = new Date(newEnd.getTime() + apptBuffer); const conflicts = await tx .select({ id: appointments.id }) .from(appointments) .where( and( eq(appointments.staffId, newStaffId), - lt(appointments.startTime, newEnd), + lt(appointments.startTime, conflictEnd), gte(appointments.endTime, newStart), ne(appointments.status, "cancelled"), ne(appointments.status, "no_show"), @@ -494,6 +516,8 @@ appointmentsRouter.patch( endDeltaMs !== 0 || updateFields.batherStaffId !== undefined) ) { + const apptBuffer = (appt.bufferMinutes ?? 0) * 60_000; + const conflictEnd = new Date(newEnd.getTime() + apptBuffer); const conflicts = await tx .select({ id: appointments.id }) .from(appointments) @@ -503,7 +527,7 @@ appointmentsRouter.patch( eq(appointments.staffId, newBatherStaffId), eq(appointments.batherStaffId, newBatherStaffId) ), - lt(appointments.startTime, newEnd), + lt(appointments.startTime, conflictEnd), gte(appointments.endTime, newStart), ne(appointments.status, "cancelled"), ne(appointments.status, "no_show"), @@ -619,13 +643,16 @@ appointmentsRouter.patch( } if (staffId) { + const currentBuffer = + (current.bufferMinutes ?? 0) * 60_000; + const conflictEnd = new Date(end.getTime() + currentBuffer); const conflicts = await tx .select({ id: appointments.id }) .from(appointments) .where( and( eq(appointments.staffId, staffId), - lt(appointments.startTime, end), + lt(appointments.startTime, conflictEnd), gte(appointments.endTime, start), ne(appointments.status, "cancelled"), ne(appointments.status, "no_show"), @@ -639,6 +666,9 @@ appointmentsRouter.patch( } if (batherStaffId) { + const currentBuffer = + (current.bufferMinutes ?? 0) * 60_000; + const conflictEnd = new Date(end.getTime() + currentBuffer); const bathConflicts = await tx .select({ id: appointments.id }) .from(appointments) @@ -648,7 +678,7 @@ appointmentsRouter.patch( eq(appointments.staffId, batherStaffId), eq(appointments.batherStaffId, batherStaffId) ), - lt(appointments.startTime, end), + lt(appointments.startTime, conflictEnd), gte(appointments.endTime, start), ne(appointments.status, "cancelled"), ne(appointments.status, "no_show"), diff --git a/apps/api/src/routes/book.ts b/apps/api/src/routes/book.ts index e15a131..1fe3ce9 100644 --- a/apps/api/src/routes/book.ts +++ b/apps/api/src/routes/book.ts @@ -17,6 +17,7 @@ import { } from "../db"; import { generateAvailableSlots, + resolveBufferMinutes, BUSINESS_START_HOUR, BUSINESS_END_HOUR, } from "../lib/slots.js"; @@ -43,6 +44,8 @@ bookRouter.get("/services", async (c) => { bookRouter.get("/availability", async (c) => { const serviceId = c.req.query("serviceId"); const dateStr = c.req.query("date"); + const petSizeCategory = c.req.query("petSizeCategory"); + const petCoatType = c.req.query("petCoatType"); if (!serviceId || !dateStr) { return c.json({ error: "serviceId and date are required" }, 400); @@ -70,12 +73,16 @@ bookRouter.get("/availability", async (c) => { const dayEnd = new Date(`${dateStr}T00:00:00Z`); dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0); - // Fetch all active appointments for the day (any groomer) + // Resolve buffer for the new appointment + const newBufferMinutes = resolveBufferMinutes({ petSizeCategory, petCoatType }); + + // Fetch all active appointments for the day (any groomer) with their buffer const booked = await db .select({ staffId: appointments.staffId, startTime: appointments.startTime, endTime: appointments.endTime, + bufferMinutes: appointments.bufferMinutes, }) .from(appointments) .where( @@ -92,6 +99,7 @@ bookRouter.get("/availability", async (c) => { durationMinutes: service.durationMinutes, groomerIds: groomers.map((g) => g.id), booked, + newBufferMinutes, }); return c.json(slots); @@ -113,6 +121,8 @@ const bookingSchema = z.object({ petSpecies: z.string().min(1).max(100), petBreed: z.string().max(100).optional(), notes: z.string().max(2000).optional(), + petSizeCategory: z.enum(["small", "medium", "large"]).optional(), + petCoatType: z.string().max(50).optional(), }); bookRouter.post( @@ -129,6 +139,12 @@ bookRouter.post( .where(and(eq(services.id, body.serviceId), eq(services.active, true))); if (!service) return c.json({ error: "Service not found" }, 404); + // Resolve buffer for the new appointment + const bufferMinutes = resolveBufferMinutes({ + petSizeCategory: body.petSizeCategory, + petCoatType: body.petCoatType, + }); + const end = new Date(start.getTime() + service.durationMinutes * 60_000); // Find all active groomers @@ -141,21 +157,40 @@ bookRouter.post( return c.json({ error: "No groomers available" }, 409); } - // Find conflicting appointments for this time window + // Find conflicting appointments for this time window (including existing buffers) + const endWithBuffer = new Date(end.getTime() + bufferMinutes * 60_000); const booked = await db - .select({ staffId: appointments.staffId }) + .select({ + staffId: appointments.staffId, + startTime: appointments.startTime, + endTime: appointments.endTime, + bufferMinutes: appointments.bufferMinutes, + }) .from(appointments) .where( and( - lt(appointments.startTime, end), + lt(appointments.startTime, endWithBuffer), gt(appointments.endTime, start), ne(appointments.status, "cancelled"), ne(appointments.status, "no_show"), ) ); - const busyIds = new Set(booked.map((a) => a.staffId)); - const freeGroomer = groomers.find(({ id }) => !busyIds.has(id)); + // Build busy groomer map: staffId -> effective end (endTime + buffer) + const busyGroomers = new Map(); + for (const b of booked) { + const effectiveEnd = b.endTime.getTime() + (b.bufferMinutes ?? 0) * 60_000; + const existing = busyGroomers.get(b.staffId ?? "") ?? 0; + if (effectiveEnd > existing) busyGroomers.set(b.staffId ?? "", effectiveEnd); + } + + // New appointment's effective end (end + its buffer) + const newEffectiveEnd = endWithBuffer.getTime(); + + const freeGroomer = groomers.find(({ id }) => { + const busyUntil = busyGroomers.get(id) ?? 0; + return busyUntil <= start.getTime(); + }); if (!freeGroomer) { return c.json( { error: "No groomers available at this time. Please choose another slot." }, @@ -206,7 +241,7 @@ bookRouter.post( .where( and( eq(appointments.staffId, freeGroomer.id), - lt(appointments.startTime, end), + lt(appointments.startTime, endWithBuffer), gt(appointments.endTime, start), ne(appointments.status, "cancelled"), ne(appointments.status, "no_show"), @@ -228,6 +263,7 @@ bookRouter.post( startTime: start, endTime: end, notes: body.notes ?? null, + bufferMinutes, }) .returning(); return apptInserted[0];