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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string[]>().default([]),
|
||||
medicalAlerts: jsonb("medical_alerts").$type<MedicalAlert[]>().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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<string, number>();
|
||||
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];
|
||||
|
||||
Reference in New Issue
Block a user