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 { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
generateAvailableSlots,
|
generateAvailableSlots,
|
||||||
|
resolveBufferMinutes,
|
||||||
BUSINESS_START_HOUR,
|
BUSINESS_START_HOUR,
|
||||||
BUSINESS_END_HOUR,
|
BUSINESS_END_HOUR,
|
||||||
} from "../lib/slots.js";
|
} from "../lib/slots.js";
|
||||||
@@ -113,4 +114,136 @@ describe("generateAvailableSlots", () => {
|
|||||||
expect(new Date(last!).getUTCHours()).toBe(16);
|
expect(new Date(last!).getUTCHours()).toBe(16);
|
||||||
expect(new Date(last!).getUTCMinutes()).toBe(30);
|
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"),
|
image: text("image"),
|
||||||
// Extended profile fields
|
// Extended profile fields
|
||||||
coatType: text("coat_type"),
|
coatType: text("coat_type"),
|
||||||
|
petSizeCategory: text("pet_size_category"), // "small" | "medium" | "large"
|
||||||
temperamentScore: integer("temperament_score"),
|
temperamentScore: integer("temperament_score"),
|
||||||
temperamentFlags: jsonb("temperament_flags").$type<string[]>().default([]),
|
temperamentFlags: jsonb("temperament_flags").$type<string[]>().default([]),
|
||||||
medicalAlerts: jsonb("medical_alerts").$type<MedicalAlert[]>().default([]),
|
medicalAlerts: jsonb("medical_alerts").$type<MedicalAlert[]>().default([]),
|
||||||
@@ -240,6 +241,8 @@ export const appointments = pgTable(
|
|||||||
startTime: timestamp("start_time").notNull(),
|
startTime: timestamp("start_time").notNull(),
|
||||||
endTime: timestamp("end_time").notNull(),
|
endTime: timestamp("end_time").notNull(),
|
||||||
notes: text("notes"),
|
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)
|
// Override price at time of booking (null = use service base price)
|
||||||
priceCents: integer("price_cents"),
|
priceCents: integer("price_cents"),
|
||||||
// Recurring series support
|
// Recurring series support
|
||||||
|
|||||||
@@ -10,22 +10,49 @@ export interface BookedSlot {
|
|||||||
staffId: string | null;
|
staffId: string | null;
|
||||||
startTime: Date;
|
startTime: Date;
|
||||||
endTime: Date;
|
endTime: Date;
|
||||||
|
bufferMinutes: number; // minutes of buffer after endTime
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate all available appointment start times for a given date,
|
* Generate all available appointment start times for a given date,
|
||||||
* returning only slots where at least one groomer is free.
|
* 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({
|
export function generateAvailableSlots({
|
||||||
dateStr,
|
dateStr,
|
||||||
durationMinutes,
|
durationMinutes,
|
||||||
groomerIds,
|
groomerIds,
|
||||||
booked,
|
booked,
|
||||||
|
newBufferMinutes = 0,
|
||||||
}: {
|
}: {
|
||||||
dateStr: string;
|
dateStr: string;
|
||||||
durationMinutes: number;
|
durationMinutes: number;
|
||||||
groomerIds: string[];
|
groomerIds: string[];
|
||||||
booked: BookedSlot[];
|
booked: BookedSlot[];
|
||||||
|
newBufferMinutes?: number;
|
||||||
}): string[] {
|
}): string[] {
|
||||||
const dayStart = new Date(`${dateStr}T00:00:00Z`);
|
const dayStart = new Date(`${dateStr}T00:00:00Z`);
|
||||||
dayStart.setUTCHours(BUSINESS_START_HOUR, 0, 0, 0);
|
dayStart.setUTCHours(BUSINESS_START_HOUR, 0, 0, 0);
|
||||||
@@ -33,18 +60,20 @@ export function generateAvailableSlots({
|
|||||||
dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0);
|
dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0);
|
||||||
|
|
||||||
const durationMs = durationMinutes * 60_000;
|
const durationMs = durationMinutes * 60_000;
|
||||||
|
const newBufferMs = newBufferMinutes * 60_000;
|
||||||
const slots: string[] = [];
|
const slots: string[] = [];
|
||||||
let slotStart = dayStart.getTime();
|
let slotStart = dayStart.getTime();
|
||||||
|
|
||||||
while (slotStart + durationMs <= dayEnd.getTime()) {
|
while (slotStart + durationMs + newBufferMs <= dayEnd.getTime()) {
|
||||||
const slotEnd = slotStart + durationMs;
|
const slotEnd = slotStart + durationMs;
|
||||||
|
const newEndWithBuffer = slotEnd + newBufferMs;
|
||||||
const hasGroomer = groomerIds.some(
|
const hasGroomer = groomerIds.some(
|
||||||
(groomerId) =>
|
(groomerId) =>
|
||||||
!booked.some(
|
!booked.some(
|
||||||
(a) =>
|
(a) =>
|
||||||
a.staffId === groomerId &&
|
a.staffId === groomerId &&
|
||||||
a.startTime.getTime() < slotEnd &&
|
a.startTime.getTime() < newEndWithBuffer &&
|
||||||
a.endTime.getTime() > slotStart
|
a.endTime.getTime() + a.bufferMinutes * 60_000 > slotStart
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
if (hasGroomer) slots.push(new Date(slotStart).toISOString());
|
if (hasGroomer) slots.push(new Date(slotStart).toISOString());
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ import {
|
|||||||
services,
|
services,
|
||||||
staff,
|
staff,
|
||||||
} from "../db";
|
} 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 { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
@@ -56,6 +62,9 @@ const createAppointmentSchema = z.object({
|
|||||||
endTime: z.string().datetime(),
|
endTime: z.string().datetime(),
|
||||||
notes: z.string().max(2000).optional(),
|
notes: z.string().max(2000).optional(),
|
||||||
priceCents: z.number().int().positive().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
|
// Optional recurrence: creates a series of N appointments every frequencyWeeks weeks
|
||||||
recurrence: z
|
recurrence: z
|
||||||
.object({
|
.object({
|
||||||
@@ -159,7 +168,14 @@ appointmentsRouter.post(
|
|||||||
return c.json({ error: "endTime must be after startTime" }, 422);
|
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
|
// Wrap conflict check + insert in a transaction to prevent double-booking
|
||||||
// race conditions under concurrent load (fixes #18).
|
// race conditions under concurrent load (fixes #18).
|
||||||
@@ -176,7 +192,7 @@ appointmentsRouter.post(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(appointments.staffId, apptFields.staffId),
|
eq(appointments.staffId, apptFields.staffId),
|
||||||
lt(appointments.startTime, end),
|
lt(appointments.startTime, endWithBuffer),
|
||||||
gte(appointments.endTime, start),
|
gte(appointments.endTime, start),
|
||||||
ne(appointments.status, "cancelled"),
|
ne(appointments.status, "cancelled"),
|
||||||
ne(appointments.status, "no_show"),
|
ne(appointments.status, "no_show"),
|
||||||
@@ -198,7 +214,7 @@ appointmentsRouter.post(
|
|||||||
eq(appointments.staffId, apptFields.batherStaffId),
|
eq(appointments.staffId, apptFields.batherStaffId),
|
||||||
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
||||||
),
|
),
|
||||||
lt(appointments.startTime, end),
|
lt(appointments.startTime, endWithBuffer),
|
||||||
gte(appointments.endTime, start),
|
gte(appointments.endTime, start),
|
||||||
ne(appointments.status, "cancelled"),
|
ne(appointments.status, "cancelled"),
|
||||||
ne(appointments.status, "no_show"),
|
ne(appointments.status, "no_show"),
|
||||||
@@ -214,7 +230,7 @@ appointmentsRouter.post(
|
|||||||
// Single appointment
|
// Single appointment
|
||||||
const [inserted] = await tx
|
const [inserted] = await tx
|
||||||
.insert(appointments)
|
.insert(appointments)
|
||||||
.values({ ...apptFields, startTime: start, endTime: end })
|
.values({ ...apptFields, startTime: start, endTime: end, bufferMinutes })
|
||||||
.returning();
|
.returning();
|
||||||
if (!inserted) throw new Error("Insert failed");
|
if (!inserted) throw new Error("Insert failed");
|
||||||
return inserted;
|
return inserted;
|
||||||
@@ -239,6 +255,9 @@ appointmentsRouter.post(
|
|||||||
const instanceEnd = new Date(
|
const instanceEnd = new Date(
|
||||||
instanceStart.getTime() + durationMs
|
instanceStart.getTime() + durationMs
|
||||||
);
|
);
|
||||||
|
const instanceEndWithBuffer = new Date(
|
||||||
|
instanceEnd.getTime() + bufferMinutes * 60_000
|
||||||
|
);
|
||||||
|
|
||||||
if (apptFields.staffId) {
|
if (apptFields.staffId) {
|
||||||
const conflicts = await tx
|
const conflicts = await tx
|
||||||
@@ -247,7 +266,7 @@ appointmentsRouter.post(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(appointments.staffId, apptFields.staffId),
|
eq(appointments.staffId, apptFields.staffId),
|
||||||
lt(appointments.startTime, instanceEnd),
|
lt(appointments.startTime, instanceEndWithBuffer),
|
||||||
gte(appointments.endTime, instanceStart),
|
gte(appointments.endTime, instanceStart),
|
||||||
ne(appointments.status, "cancelled"),
|
ne(appointments.status, "cancelled"),
|
||||||
ne(appointments.status, "no_show"),
|
ne(appointments.status, "no_show"),
|
||||||
@@ -269,7 +288,7 @@ appointmentsRouter.post(
|
|||||||
eq(appointments.staffId, apptFields.batherStaffId),
|
eq(appointments.staffId, apptFields.batherStaffId),
|
||||||
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
||||||
),
|
),
|
||||||
lt(appointments.startTime, instanceEnd),
|
lt(appointments.startTime, instanceEndWithBuffer),
|
||||||
gte(appointments.endTime, instanceStart),
|
gte(appointments.endTime, instanceStart),
|
||||||
ne(appointments.status, "cancelled"),
|
ne(appointments.status, "cancelled"),
|
||||||
ne(appointments.status, "no_show"),
|
ne(appointments.status, "no_show"),
|
||||||
@@ -289,6 +308,7 @@ appointmentsRouter.post(
|
|||||||
endTime: instanceEnd,
|
endTime: instanceEnd,
|
||||||
seriesId: series.id,
|
seriesId: series.id,
|
||||||
seriesIndex: i,
|
seriesIndex: i,
|
||||||
|
bufferMinutes,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
if (!inserted) throw new Error(`Insert failed for occurrence ${i}`);
|
if (!inserted) throw new Error(`Insert failed for occurrence ${i}`);
|
||||||
@@ -469,13 +489,15 @@ appointmentsRouter.patch(
|
|||||||
endDeltaMs !== 0 ||
|
endDeltaMs !== 0 ||
|
||||||
updateFields.staffId !== undefined)
|
updateFields.staffId !== undefined)
|
||||||
) {
|
) {
|
||||||
|
const apptBuffer = (appt.bufferMinutes ?? 0) * 60_000;
|
||||||
|
const conflictEnd = new Date(newEnd.getTime() + apptBuffer);
|
||||||
const conflicts = await tx
|
const conflicts = await tx
|
||||||
.select({ id: appointments.id })
|
.select({ id: appointments.id })
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(appointments.staffId, newStaffId),
|
eq(appointments.staffId, newStaffId),
|
||||||
lt(appointments.startTime, newEnd),
|
lt(appointments.startTime, conflictEnd),
|
||||||
gte(appointments.endTime, newStart),
|
gte(appointments.endTime, newStart),
|
||||||
ne(appointments.status, "cancelled"),
|
ne(appointments.status, "cancelled"),
|
||||||
ne(appointments.status, "no_show"),
|
ne(appointments.status, "no_show"),
|
||||||
@@ -494,6 +516,8 @@ appointmentsRouter.patch(
|
|||||||
endDeltaMs !== 0 ||
|
endDeltaMs !== 0 ||
|
||||||
updateFields.batherStaffId !== undefined)
|
updateFields.batherStaffId !== undefined)
|
||||||
) {
|
) {
|
||||||
|
const apptBuffer = (appt.bufferMinutes ?? 0) * 60_000;
|
||||||
|
const conflictEnd = new Date(newEnd.getTime() + apptBuffer);
|
||||||
const conflicts = await tx
|
const conflicts = await tx
|
||||||
.select({ id: appointments.id })
|
.select({ id: appointments.id })
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
@@ -503,7 +527,7 @@ appointmentsRouter.patch(
|
|||||||
eq(appointments.staffId, newBatherStaffId),
|
eq(appointments.staffId, newBatherStaffId),
|
||||||
eq(appointments.batherStaffId, newBatherStaffId)
|
eq(appointments.batherStaffId, newBatherStaffId)
|
||||||
),
|
),
|
||||||
lt(appointments.startTime, newEnd),
|
lt(appointments.startTime, conflictEnd),
|
||||||
gte(appointments.endTime, newStart),
|
gte(appointments.endTime, newStart),
|
||||||
ne(appointments.status, "cancelled"),
|
ne(appointments.status, "cancelled"),
|
||||||
ne(appointments.status, "no_show"),
|
ne(appointments.status, "no_show"),
|
||||||
@@ -619,13 +643,16 @@ appointmentsRouter.patch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (staffId) {
|
if (staffId) {
|
||||||
|
const currentBuffer =
|
||||||
|
(current.bufferMinutes ?? 0) * 60_000;
|
||||||
|
const conflictEnd = new Date(end.getTime() + currentBuffer);
|
||||||
const conflicts = await tx
|
const conflicts = await tx
|
||||||
.select({ id: appointments.id })
|
.select({ id: appointments.id })
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(appointments.staffId, staffId),
|
eq(appointments.staffId, staffId),
|
||||||
lt(appointments.startTime, end),
|
lt(appointments.startTime, conflictEnd),
|
||||||
gte(appointments.endTime, start),
|
gte(appointments.endTime, start),
|
||||||
ne(appointments.status, "cancelled"),
|
ne(appointments.status, "cancelled"),
|
||||||
ne(appointments.status, "no_show"),
|
ne(appointments.status, "no_show"),
|
||||||
@@ -639,6 +666,9 @@ appointmentsRouter.patch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (batherStaffId) {
|
if (batherStaffId) {
|
||||||
|
const currentBuffer =
|
||||||
|
(current.bufferMinutes ?? 0) * 60_000;
|
||||||
|
const conflictEnd = new Date(end.getTime() + currentBuffer);
|
||||||
const bathConflicts = await tx
|
const bathConflicts = await tx
|
||||||
.select({ id: appointments.id })
|
.select({ id: appointments.id })
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
@@ -648,7 +678,7 @@ appointmentsRouter.patch(
|
|||||||
eq(appointments.staffId, batherStaffId),
|
eq(appointments.staffId, batherStaffId),
|
||||||
eq(appointments.batherStaffId, batherStaffId)
|
eq(appointments.batherStaffId, batherStaffId)
|
||||||
),
|
),
|
||||||
lt(appointments.startTime, end),
|
lt(appointments.startTime, conflictEnd),
|
||||||
gte(appointments.endTime, start),
|
gte(appointments.endTime, start),
|
||||||
ne(appointments.status, "cancelled"),
|
ne(appointments.status, "cancelled"),
|
||||||
ne(appointments.status, "no_show"),
|
ne(appointments.status, "no_show"),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "../db";
|
} from "../db";
|
||||||
import {
|
import {
|
||||||
generateAvailableSlots,
|
generateAvailableSlots,
|
||||||
|
resolveBufferMinutes,
|
||||||
BUSINESS_START_HOUR,
|
BUSINESS_START_HOUR,
|
||||||
BUSINESS_END_HOUR,
|
BUSINESS_END_HOUR,
|
||||||
} from "../lib/slots.js";
|
} from "../lib/slots.js";
|
||||||
@@ -43,6 +44,8 @@ bookRouter.get("/services", async (c) => {
|
|||||||
bookRouter.get("/availability", async (c) => {
|
bookRouter.get("/availability", async (c) => {
|
||||||
const serviceId = c.req.query("serviceId");
|
const serviceId = c.req.query("serviceId");
|
||||||
const dateStr = c.req.query("date");
|
const dateStr = c.req.query("date");
|
||||||
|
const petSizeCategory = c.req.query("petSizeCategory");
|
||||||
|
const petCoatType = c.req.query("petCoatType");
|
||||||
|
|
||||||
if (!serviceId || !dateStr) {
|
if (!serviceId || !dateStr) {
|
||||||
return c.json({ error: "serviceId and date are required" }, 400);
|
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`);
|
const dayEnd = new Date(`${dateStr}T00:00:00Z`);
|
||||||
dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0);
|
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
|
const booked = await db
|
||||||
.select({
|
.select({
|
||||||
staffId: appointments.staffId,
|
staffId: appointments.staffId,
|
||||||
startTime: appointments.startTime,
|
startTime: appointments.startTime,
|
||||||
endTime: appointments.endTime,
|
endTime: appointments.endTime,
|
||||||
|
bufferMinutes: appointments.bufferMinutes,
|
||||||
})
|
})
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
.where(
|
.where(
|
||||||
@@ -92,6 +99,7 @@ bookRouter.get("/availability", async (c) => {
|
|||||||
durationMinutes: service.durationMinutes,
|
durationMinutes: service.durationMinutes,
|
||||||
groomerIds: groomers.map((g) => g.id),
|
groomerIds: groomers.map((g) => g.id),
|
||||||
booked,
|
booked,
|
||||||
|
newBufferMinutes,
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json(slots);
|
return c.json(slots);
|
||||||
@@ -113,6 +121,8 @@ const bookingSchema = z.object({
|
|||||||
petSpecies: z.string().min(1).max(100),
|
petSpecies: z.string().min(1).max(100),
|
||||||
petBreed: z.string().max(100).optional(),
|
petBreed: z.string().max(100).optional(),
|
||||||
notes: z.string().max(2000).optional(),
|
notes: z.string().max(2000).optional(),
|
||||||
|
petSizeCategory: z.enum(["small", "medium", "large"]).optional(),
|
||||||
|
petCoatType: z.string().max(50).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
bookRouter.post(
|
bookRouter.post(
|
||||||
@@ -129,6 +139,12 @@ bookRouter.post(
|
|||||||
.where(and(eq(services.id, body.serviceId), eq(services.active, true)));
|
.where(and(eq(services.id, body.serviceId), eq(services.active, true)));
|
||||||
if (!service) return c.json({ error: "Service not found" }, 404);
|
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);
|
const end = new Date(start.getTime() + service.durationMinutes * 60_000);
|
||||||
|
|
||||||
// Find all active groomers
|
// Find all active groomers
|
||||||
@@ -141,21 +157,40 @@ bookRouter.post(
|
|||||||
return c.json({ error: "No groomers available" }, 409);
|
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
|
const booked = await db
|
||||||
.select({ staffId: appointments.staffId })
|
.select({
|
||||||
|
staffId: appointments.staffId,
|
||||||
|
startTime: appointments.startTime,
|
||||||
|
endTime: appointments.endTime,
|
||||||
|
bufferMinutes: appointments.bufferMinutes,
|
||||||
|
})
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
lt(appointments.startTime, end),
|
lt(appointments.startTime, endWithBuffer),
|
||||||
gt(appointments.endTime, start),
|
gt(appointments.endTime, start),
|
||||||
ne(appointments.status, "cancelled"),
|
ne(appointments.status, "cancelled"),
|
||||||
ne(appointments.status, "no_show"),
|
ne(appointments.status, "no_show"),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const busyIds = new Set(booked.map((a) => a.staffId));
|
// Build busy groomer map: staffId -> effective end (endTime + buffer)
|
||||||
const freeGroomer = groomers.find(({ id }) => !busyIds.has(id));
|
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) {
|
if (!freeGroomer) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: "No groomers available at this time. Please choose another slot." },
|
{ error: "No groomers available at this time. Please choose another slot." },
|
||||||
@@ -206,7 +241,7 @@ bookRouter.post(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(appointments.staffId, freeGroomer.id),
|
eq(appointments.staffId, freeGroomer.id),
|
||||||
lt(appointments.startTime, end),
|
lt(appointments.startTime, endWithBuffer),
|
||||||
gt(appointments.endTime, start),
|
gt(appointments.endTime, start),
|
||||||
ne(appointments.status, "cancelled"),
|
ne(appointments.status, "cancelled"),
|
||||||
ne(appointments.status, "no_show"),
|
ne(appointments.status, "no_show"),
|
||||||
@@ -228,6 +263,7 @@ bookRouter.post(
|
|||||||
startTime: start,
|
startTime: start,
|
||||||
endTime: end,
|
endTime: end,
|
||||||
notes: body.notes ?? null,
|
notes: body.notes ?? null,
|
||||||
|
bufferMinutes,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
return apptInserted[0];
|
return apptInserted[0];
|
||||||
|
|||||||
Reference in New Issue
Block a user