Booking Engine
Architecture: Six focused services with the BookingService/SchedulingService overlap resolved by giving SchedulingService exactly one job, the transactional double-booking guard , while BookingService owns the create/reschedule/cancel workflow around it. 59 unit tests, 24 integration tests
This commit is contained in:
28 files changed
+4853
-15
No files matched your search
@@ -0,0 +1,55 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { SchedulingTestClient } from "./scheduling-test-client";
|
||||
|
||||
export const metadata: Metadata = { title: "Scheduling Engine — Dev Test" };
|
||||
// Always reflect live DB state — this is a manual test harness, not a page that should serve a build-time snapshot.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Internal-only harness for manually exercising the booking engine
|
||||
* (Phase 3) — not a customer-facing screen, not linked from any nav. Uses
|
||||
* the seeded demo business/staff/customer from prisma/seed.ts. See
|
||||
* docs/phase-3-summary.md.
|
||||
*/
|
||||
export default async function SchedulingTestPage() {
|
||||
const business = await prisma.business.findUnique({
|
||||
where: { slug: "blake-heritage-barbershop" },
|
||||
select: { id: true, name: true, timezone: true },
|
||||
});
|
||||
if (!business) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [services, staff, customer] = await Promise.all([
|
||||
prisma.service.findMany({
|
||||
where: { businessId: business.id, isActive: true },
|
||||
orderBy: { displayOrder: "asc" },
|
||||
}),
|
||||
prisma.staffProfile.findMany({
|
||||
where: { businessId: business.id },
|
||||
include: { user: { select: { name: true } } },
|
||||
}),
|
||||
prisma.customerProfile.findFirst({
|
||||
where: { email: "alex.chen@example.com" },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!customer) {
|
||||
return (
|
||||
<div className="p-8 text-sm text-red-500">
|
||||
No seeded customer found — run <code>npm run prisma:seed</code> first.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SchedulingTestClient
|
||||
business={business}
|
||||
services={services.map((s) => ({ id: s.id, name: s.name, durationMinutes: s.durationMinutes, price: s.price.toString() }))}
|
||||
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Staff member" }))}
|
||||
customerId={customer.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import {
|
||||
getAvailableDatesAction,
|
||||
getAvailableTimeSlotsAction,
|
||||
} from "@/features/booking/actions/availability";
|
||||
import {
|
||||
cancelBookingAction,
|
||||
createBookingAction,
|
||||
rescheduleBookingAction,
|
||||
} from "@/features/booking/actions/booking";
|
||||
import {
|
||||
checkInAction,
|
||||
completeAppointmentAction,
|
||||
listAppointmentsAction,
|
||||
markNoShowAction,
|
||||
startAppointmentAction,
|
||||
} from "@/features/booking/actions/appointment";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { MonthCalendar } from "@/components/ui/month-calendar";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { StatusPill } from "@/components/ui/status-pill";
|
||||
import type { AppointmentStatus } from "@/types/domain";
|
||||
|
||||
interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
durationMinutes: number;
|
||||
price: string;
|
||||
}
|
||||
interface Staff {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
interface TimeSlot {
|
||||
startMinutes: number;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
staffOptions: string[];
|
||||
}
|
||||
|
||||
function toYMD(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const EMPTY_SET: Set<string> = new Set();
|
||||
const EMPTY_SLOTS: TimeSlot[] = [];
|
||||
|
||||
const STATUS_MAP: Record<string, AppointmentStatus> = {
|
||||
PENDING: "pending_approval",
|
||||
CONFIRMED: "confirmed",
|
||||
CHECKED_IN: "arrived",
|
||||
IN_PROGRESS: "in_progress",
|
||||
COMPLETED: "completed",
|
||||
CANCELLED: "cancelled",
|
||||
NO_SHOW: "no_show",
|
||||
};
|
||||
|
||||
export function SchedulingTestClient(props: {
|
||||
business: { id: string; name: string; timezone: string };
|
||||
services: Service[];
|
||||
staff: Staff[];
|
||||
customerId: string;
|
||||
}) {
|
||||
const { business, services, staff, customerId } = props;
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const [selectedServiceIds, setSelectedServiceIds] = useState<string[]>(services[0] ? [services[0].id] : []);
|
||||
const [selectedStaffId, setSelectedStaffId] = useState<string>("any");
|
||||
const [month, setMonth] = useState(() => new Date());
|
||||
const [availableDates, setAvailableDates] = useState<Set<string>>(new Set());
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>();
|
||||
const [slots, setSlots] = useState<TimeSlot[]>([]);
|
||||
const [notes, setNotes] = useState("");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [appointments, setAppointments] = useState<any[]>([]);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const refreshAppointments = () => {
|
||||
listAppointmentsAction({ businessId: business.id, businessTimezone: business.timezone }).then(setAppointments);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshAppointments();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Available dates for the visible month. When nothing is selected there's
|
||||
// nothing to fetch — `effectiveAvailableDates` below derives the empty
|
||||
// case at render time rather than this effect resetting state
|
||||
// synchronously (which cascades an extra render for no reason).
|
||||
useEffect(() => {
|
||||
if (selectedServiceIds.length === 0) return;
|
||||
const from = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
const to = new Date(month.getFullYear(), month.getMonth() + 1, 0);
|
||||
getAvailableDatesAction({
|
||||
businessId: business.id,
|
||||
serviceIds: selectedServiceIds,
|
||||
staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
|
||||
fromDate: toYMD(from),
|
||||
toDate: toYMD(to),
|
||||
}).then((res) => setAvailableDates(new Set(res.dates)));
|
||||
}, [business.id, month, selectedServiceIds, selectedStaffId]);
|
||||
const effectiveAvailableDates = selectedServiceIds.length === 0 ? EMPTY_SET : availableDates;
|
||||
|
||||
// Time slots for the selected date — same derive-at-render-time pattern.
|
||||
useEffect(() => {
|
||||
if (!selectedDate || selectedServiceIds.length === 0) return;
|
||||
getAvailableTimeSlotsAction({
|
||||
businessId: business.id,
|
||||
serviceIds: selectedServiceIds,
|
||||
staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
|
||||
date: toYMD(selectedDate),
|
||||
}).then((res) => setSlots(res.slots));
|
||||
}, [business.id, selectedDate, selectedServiceIds, selectedStaffId]);
|
||||
const effectiveSlots = !selectedDate || selectedServiceIds.length === 0 ? EMPTY_SLOTS : slots;
|
||||
|
||||
const toggleService = (id: string) => {
|
||||
setSelectedServiceIds((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const book = (slot: TimeSlot) => {
|
||||
startTransition(async () => {
|
||||
const result = await createBookingAction({
|
||||
businessId: business.id,
|
||||
customerId,
|
||||
serviceIds: selectedServiceIds,
|
||||
staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
|
||||
startAt: slot.startAt,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
if (result.success) {
|
||||
setMessage(`Booked: ${result.appointment.id}`);
|
||||
setNotes("");
|
||||
setSelectedDate(undefined);
|
||||
refreshAppointments();
|
||||
} else {
|
||||
setMessage(`Failed: ${result.validation.issues.map((i) => `${i.code} — ${i.message}`).join("; ")}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const cancel = (appointmentId: string) => {
|
||||
startTransition(async () => {
|
||||
const result = await cancelBookingAction({ appointmentId, cancelledByUserId: customerId, reason: "Dev test" });
|
||||
setMessage(result.success ? "Cancelled" : `Failed: ${result.validation.issues.map((i) => i.message).join("; ")}`);
|
||||
refreshAppointments();
|
||||
});
|
||||
};
|
||||
|
||||
const reschedule = (appointmentId: string) => {
|
||||
const input = window.prompt("New start time (ISO, e.g. 2026-08-20T10:00:00.000Z):");
|
||||
if (!input) return;
|
||||
startTransition(async () => {
|
||||
const result = await rescheduleBookingAction({ appointmentId, startAt: input });
|
||||
setMessage(result.success ? "Rescheduled" : `Failed: ${result.validation.issues.map((i) => i.message).join("; ")}`);
|
||||
refreshAppointments();
|
||||
});
|
||||
};
|
||||
|
||||
const runTransition = (fn: (id: string) => Promise<{ success: boolean }>, appointmentId: string) => {
|
||||
startTransition(async () => {
|
||||
const result = await fn(appointmentId);
|
||||
setMessage(result.success ? "Status updated" : "Transition rejected");
|
||||
refreshAppointments();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-8 px-6 py-10">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold">Scheduling Engine — Dev Test</h1>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
{business.name} · {business.timezone} · not a real feature page, see docs/phase-3-summary.md
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{message && (
|
||||
<div className="rounded-xl border border-line bg-white p-3 text-sm" role="status">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="space-y-4 p-6">
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-muted">1. Services</h2>
|
||||
<div className="space-y-2">
|
||||
{services.map((s) => (
|
||||
<Checkbox
|
||||
key={s.id}
|
||||
label={`${s.name} (${s.durationMinutes}m, £${s.price})`}
|
||||
checked={selectedServiceIds.includes(s.id)}
|
||||
onChange={() => toggleService(s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="pt-2 text-xs font-bold uppercase tracking-widest text-muted">2. Barber</h2>
|
||||
<Select value={selectedStaffId} onChange={(e) => setSelectedStaffId(e.target.value)}>
|
||||
<option value="any">Anyone available</option>
|
||||
{staff.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h2 className="mb-4 text-xs font-bold uppercase tracking-widest text-muted">3. Date</h2>
|
||||
<MonthCalendar
|
||||
month={month}
|
||||
onMonthChange={setMonth}
|
||||
selectedDate={selectedDate}
|
||||
onSelectDate={setSelectedDate}
|
||||
isDateDisabled={(date) => !effectiveAvailableDates.has(toYMD(date))}
|
||||
/>
|
||||
<p className="mt-3 text-xs text-muted">Selectable dates are ones the engine reports as having openings.</p>
|
||||
</Card>
|
||||
|
||||
{selectedDate && (
|
||||
<Card className="space-y-4 p-6">
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-muted">4. Time</h2>
|
||||
{effectiveSlots.length === 0 ? (
|
||||
<p className="text-sm text-muted">No available slots this day for this selection.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{effectiveSlots.map((slot) => (
|
||||
<button
|
||||
key={slot.startAt}
|
||||
onClick={() => book(slot)}
|
||||
disabled={isPending}
|
||||
className="rounded-lg border border-line px-4 py-2 text-sm font-medium hover:border-ink disabled:opacity-50"
|
||||
>
|
||||
{new Date(slot.startAt).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: business.timezone })}
|
||||
<span className="ml-1 text-[10px] text-muted">({slot.staffOptions.length} avail.)</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Notes (optional)"
|
||||
className="w-full rounded-lg border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="space-y-3 p-6">
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-muted">Appointments</h2>
|
||||
{appointments.length === 0 && <p className="text-sm text-muted">None yet.</p>}
|
||||
{appointments.map((a) => (
|
||||
<div key={a.id} className="rounded-xl border border-line p-4 text-sm">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<StatusPill status={STATUS_MAP[a.status] ?? "confirmed"} />
|
||||
<span className="text-xs text-muted">{new Date(a.startAt).toLocaleString("en-GB", { timeZone: business.timezone })}</span>
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-muted">
|
||||
{a.staff.user.name} · {a.services.map((s: { service: { name: string } }) => s.service.name).join(" + ")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => runTransition((id) => checkInAction(id), a.id)} disabled={isPending}>
|
||||
Check in
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => runTransition((id) => startAppointmentAction(id), a.id)} disabled={isPending}>
|
||||
Start
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => runTransition((id) => completeAppointmentAction(id), a.id)} disabled={isPending}>
|
||||
Complete
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => runTransition((id) => markNoShowAction(id), a.id)} disabled={isPending}>
|
||||
No-show
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => reschedule(a.id)} disabled={isPending}>
|
||||
Reschedule
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => cancel(a.id)} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import * as AppointmentService from "@/services/scheduling/appointment.service";
|
||||
|
||||
export async function getAppointmentAction(appointmentId: string) {
|
||||
return AppointmentService.getAppointmentById(prisma, appointmentId);
|
||||
}
|
||||
|
||||
export async function listAppointmentsAction(params: Parameters<typeof AppointmentService.listAppointments>[1]) {
|
||||
return AppointmentService.listAppointments(prisma, params);
|
||||
}
|
||||
|
||||
// Next's Server Actions compiler statically analyses "use server" files for
|
||||
// exported `async function` declarations — a `const` bound to a factory's
|
||||
// return value (however trivial) isn't recognized as a callable action, so
|
||||
// each one below is a real function declaration even though the bodies
|
||||
// are identical apart from which AppointmentService transition they call.
|
||||
|
||||
export async function checkInAction(appointmentId: string) {
|
||||
const result = await AppointmentService.checkIn(prisma, appointmentId);
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function startAppointmentAction(appointmentId: string) {
|
||||
const result = await AppointmentService.start(prisma, appointmentId);
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function completeAppointmentAction(appointmentId: string) {
|
||||
const result = await AppointmentService.complete(prisma, appointmentId);
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function markNoShowAction(appointmentId: string) {
|
||||
const result = await AppointmentService.markNoShow(prisma, appointmentId);
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function confirmAppointmentAction(appointmentId: string) {
|
||||
const result = await AppointmentService.confirm(prisma, appointmentId);
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import * as AvailabilityService from "@/services/scheduling/availability.service";
|
||||
import { fail } from "@/services/scheduling/types";
|
||||
import {
|
||||
getAvailableBarbersSchema,
|
||||
getAvailableDatesSchema,
|
||||
getAvailableServicesSchema,
|
||||
getAvailableTimeSlotsSchema,
|
||||
} from "../schemas";
|
||||
|
||||
/**
|
||||
* Thin wrappers over AvailabilityService — no business logic here, only
|
||||
* input parsing and the client↔server boundary. See
|
||||
* docs/implementation-plan.md §11 ("Server Actions... orchestrate the
|
||||
* appropriate domain services").
|
||||
*/
|
||||
|
||||
export async function getAvailableServicesAction(input: unknown) {
|
||||
const parsed = getAvailableServicesSchema.safeParse(input);
|
||||
if (!parsed.success) return { services: [], validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
|
||||
const services = await AvailabilityService.getAvailableServices(prisma, parsed.data);
|
||||
return { services };
|
||||
}
|
||||
|
||||
export async function getAvailableBarbersAction(input: unknown) {
|
||||
const parsed = getAvailableBarbersSchema.safeParse(input);
|
||||
if (!parsed.success) return { staff: [], validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
|
||||
const staff = await AvailabilityService.getAvailableStaff(prisma, parsed.data);
|
||||
return { staff };
|
||||
}
|
||||
|
||||
export async function getAvailableDatesAction(input: unknown) {
|
||||
const parsed = getAvailableDatesSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { dates: [], validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
|
||||
}
|
||||
return AvailabilityService.getAvailableDates(prisma, parsed.data);
|
||||
}
|
||||
|
||||
export async function getAvailableTimeSlotsAction(input: unknown) {
|
||||
const parsed = getAvailableTimeSlotsSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { slots: [], validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
|
||||
}
|
||||
return AvailabilityService.getAvailableTimeSlots(prisma, parsed.data);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import * as BookingService from "@/services/scheduling/booking.service";
|
||||
import { fail } from "@/services/scheduling/types";
|
||||
import { cancelBookingSchema, createBookingSchema, rescheduleBookingSchema, updateBookingNotesSchema } from "../schemas";
|
||||
|
||||
export async function createBookingAction(input: unknown) {
|
||||
const parsed = createBookingSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false as const, validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
|
||||
|
||||
const result = await BookingService.createBooking(prisma, {
|
||||
...parsed.data,
|
||||
startAt: new Date(parsed.data.startAt),
|
||||
});
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function rescheduleBookingAction(input: unknown) {
|
||||
const parsed = rescheduleBookingSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false as const, validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
|
||||
|
||||
const result = await BookingService.rescheduleBooking(prisma, {
|
||||
...parsed.data,
|
||||
startAt: new Date(parsed.data.startAt),
|
||||
});
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function cancelBookingAction(input: unknown) {
|
||||
const parsed = cancelBookingSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false as const, validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
|
||||
|
||||
const result = await BookingService.cancelBooking(prisma, parsed.data);
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
|
||||
/** "Update booking" — notes only. Time/staff changes go through reschedule (a distinct, re-validated workflow), not this. */
|
||||
export async function updateBookingNotesAction(input: unknown) {
|
||||
const parsed = updateBookingNotesSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { success: false as const, validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
|
||||
}
|
||||
|
||||
const appointment = await prisma.appointment.update({
|
||||
where: { id: parsed.data.appointmentId },
|
||||
data: { notes: parsed.data.notes },
|
||||
include: { services: { include: { service: true } }, staff: { include: { user: true } }, customer: true },
|
||||
});
|
||||
revalidatePath("/dev/scheduling-test");
|
||||
return { success: true as const, appointment };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Input shapes for the booking Server Actions. Kept separate from the
|
||||
* scheduling services' own TS types deliberately — these describe what
|
||||
* crosses the client→server boundary as JSON (dates as ISO strings, no
|
||||
* Date objects), the services describe the domain.
|
||||
*/
|
||||
|
||||
export const getAvailableServicesSchema = z.object({
|
||||
businessId: z.string(),
|
||||
categoryId: z.string().optional(),
|
||||
staffId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const getAvailableBarbersSchema = z.object({
|
||||
businessId: z.string(),
|
||||
serviceIds: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
export const getAvailableDatesSchema = z.object({
|
||||
businessId: z.string(),
|
||||
serviceIds: z.array(z.string()).min(1),
|
||||
staffId: z.string().optional(),
|
||||
fromDate: z.string(),
|
||||
toDate: z.string(),
|
||||
});
|
||||
|
||||
export const getAvailableTimeSlotsSchema = z.object({
|
||||
businessId: z.string(),
|
||||
serviceIds: z.array(z.string()).min(1),
|
||||
staffId: z.string().optional(),
|
||||
date: z.string(),
|
||||
});
|
||||
|
||||
export const createBookingSchema = z.object({
|
||||
businessId: z.string(),
|
||||
customerId: z.string(),
|
||||
serviceIds: z.array(z.string()).min(1),
|
||||
staffId: z.string().optional(),
|
||||
startAt: z.iso.datetime(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
export const rescheduleBookingSchema = z.object({
|
||||
appointmentId: z.string(),
|
||||
staffId: z.string().optional(),
|
||||
startAt: z.iso.datetime(),
|
||||
});
|
||||
|
||||
export const cancelBookingSchema = z.object({
|
||||
appointmentId: z.string(),
|
||||
cancelledByUserId: z.string(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
export const updateBookingNotesSchema = z.object({
|
||||
appointmentId: z.string(),
|
||||
notes: z.string().max(2000),
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import {
|
||||
addDays,
|
||||
calendarDateOf,
|
||||
compareCalendarDates,
|
||||
eachCalendarDate,
|
||||
isAfterCalendarDate,
|
||||
isBeforeCalendarDate,
|
||||
minutesFromMidnight,
|
||||
todayIn,
|
||||
wallClockToInstant,
|
||||
weekdayOf,
|
||||
} from "../business-time";
|
||||
|
||||
describe("weekdayOf", () => {
|
||||
it("maps calendar dates to the correct Weekday", () => {
|
||||
// 2026-08-03 is a Monday.
|
||||
expect(weekdayOf("2026-08-03")).toBe(Weekday.MONDAY);
|
||||
expect(weekdayOf("2026-08-04")).toBe(Weekday.TUESDAY);
|
||||
expect(weekdayOf("2026-08-08")).toBe(Weekday.SATURDAY);
|
||||
expect(weekdayOf("2026-08-09")).toBe(Weekday.SUNDAY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wallClockToInstant / minutesFromMidnight round-trip", () => {
|
||||
it("round-trips a plain wall-clock time in a fixed-offset zone", () => {
|
||||
const instant = wallClockToInstant("2026-01-15", 9 * 60 + 30, "Europe/London");
|
||||
expect(minutesFromMidnight(instant, "Europe/London")).toBe(9 * 60 + 30);
|
||||
expect(calendarDateOf(instant, "Europe/London")).toBe("2026-01-15");
|
||||
});
|
||||
|
||||
it("produces the documented UTC instant for a known GMT (winter) date", () => {
|
||||
// 2026-01-15 is outside BST — Europe/London is UTC+0.
|
||||
const instant = wallClockToInstant("2026-01-15", 10 * 60, "Europe/London");
|
||||
expect(instant.toISOString()).toBe("2026-01-15T10:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DST correctness (Europe/London BST transition, last Sunday of March 2026)", () => {
|
||||
it("the same wall-clock time yields UTC instants shifted by the DST offset change", () => {
|
||||
// Before the 2026-03-29 transition: Europe/London is GMT (UTC+0).
|
||||
const beforeTransition = wallClockToInstant("2026-03-28", 10 * 60, "Europe/London");
|
||||
expect(beforeTransition.toISOString()).toBe("2026-03-28T10:00:00.000Z");
|
||||
|
||||
// After the transition: Europe/London is BST (UTC+1).
|
||||
const afterTransition = wallClockToInstant("2026-03-30", 10 * 60, "Europe/London");
|
||||
expect(afterTransition.toISOString()).toBe("2026-03-30T09:00:00.000Z");
|
||||
|
||||
// A naive "always UTC+0" implementation would put these exactly 48h
|
||||
// apart; DST-correct math puts them 47h apart because a wall-clock
|
||||
// hour was skipped.
|
||||
const diffHours = (afterTransition.getTime() - beforeTransition.getTime()) / 3_600_000;
|
||||
expect(diffHours).toBe(47);
|
||||
});
|
||||
|
||||
it("minutesFromMidnight recovers the same local wall-clock time on both sides of the transition", () => {
|
||||
const before = wallClockToInstant("2026-03-28", 10 * 60, "Europe/London");
|
||||
const after = wallClockToInstant("2026-03-30", 10 * 60, "Europe/London");
|
||||
expect(minutesFromMidnight(before, "Europe/London")).toBe(10 * 60);
|
||||
expect(minutesFromMidnight(after, "Europe/London")).toBe(10 * 60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calendar date arithmetic", () => {
|
||||
it("addDays is pure calendar-date math, independent of any zone", () => {
|
||||
expect(addDays("2026-02-27", 1)).toBe("2026-02-28");
|
||||
expect(addDays("2026-02-28", 1)).toBe("2026-03-01"); // 2026 is not a leap year
|
||||
expect(addDays("2026-12-31", 1)).toBe("2027-01-01");
|
||||
});
|
||||
|
||||
it("compares calendar dates correctly", () => {
|
||||
expect(compareCalendarDates("2026-01-01", "2026-01-02")).toBeLessThan(0);
|
||||
expect(compareCalendarDates("2026-01-02", "2026-01-01")).toBeGreaterThan(0);
|
||||
expect(compareCalendarDates("2026-01-01", "2026-01-01")).toBe(0);
|
||||
expect(isBeforeCalendarDate("2026-01-01", "2026-01-02")).toBe(true);
|
||||
expect(isAfterCalendarDate("2026-01-02", "2026-01-01")).toBe(true);
|
||||
});
|
||||
|
||||
it("eachCalendarDate yields an inclusive range and nothing when from > to", () => {
|
||||
expect([...eachCalendarDate("2026-01-01", "2026-01-03")]).toEqual([
|
||||
"2026-01-01",
|
||||
"2026-01-02",
|
||||
"2026-01-03",
|
||||
]);
|
||||
expect([...eachCalendarDate("2026-01-03", "2026-01-01")]).toEqual([]);
|
||||
});
|
||||
|
||||
it("eachCalendarDate guards against absurdly large ranges", () => {
|
||||
expect(() => [...eachCalendarDate("2026-01-01", "2030-01-01")]).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("todayIn", () => {
|
||||
it("returns a well-formed calendar date", () => {
|
||||
expect(todayIn("Europe/London")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { DateTime } from "luxon";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* All scheduling math (working hours, breaks, booking rules) happens in
|
||||
* two currencies: a `CalendarDate` ("2026-08-12", no time zone attached —
|
||||
* a calendar date is a calendar date regardless of zone) and
|
||||
* minutes-since-local-midnight (0–1439) for wall-clock times within a
|
||||
* day. Appointments themselves are stored as UTC `Date` instants (see
|
||||
* prisma/schema.prisma, Appointment.startAt/endAt). This module is the
|
||||
* ONLY place that converts between the two, via Luxon, so DST transitions
|
||||
* are handled correctly in exactly one place instead of scattered
|
||||
* `getHours()`/`setHours()` calls that silently assume the server's own
|
||||
* time zone. See docs/implementation-plan.md §14.
|
||||
*/
|
||||
|
||||
export type CalendarDate = string; // "YYYY-MM-DD"
|
||||
|
||||
const ISO_WEEKDAY_ORDER: Weekday[] = [
|
||||
Weekday.MONDAY,
|
||||
Weekday.TUESDAY,
|
||||
Weekday.WEDNESDAY,
|
||||
Weekday.THURSDAY,
|
||||
Weekday.FRIDAY,
|
||||
Weekday.SATURDAY,
|
||||
Weekday.SUNDAY,
|
||||
];
|
||||
|
||||
function requireISODate(dt: DateTime, context: string): string {
|
||||
const iso = dt.toISODate();
|
||||
if (!iso) throw new Error(`business-time: invalid date in ${context}`);
|
||||
return iso;
|
||||
}
|
||||
|
||||
/** Which Weekday a calendar date falls on, independent of time zone (a calendar date has no time-of-day ambiguity). */
|
||||
export function weekdayOf(date: CalendarDate): Weekday {
|
||||
const dt = DateTime.fromISO(date, { zone: "utc" });
|
||||
return ISO_WEEKDAY_ORDER[dt.weekday - 1]!;
|
||||
}
|
||||
|
||||
/** Minutes since local midnight (0–1439) for a UTC instant, as observed in `zone`. */
|
||||
export function minutesFromMidnight(instant: Date, zone: string): number {
|
||||
const dt = DateTime.fromJSDate(instant, { zone });
|
||||
return dt.hour * 60 + dt.minute;
|
||||
}
|
||||
|
||||
/** The business-local calendar date a UTC instant falls on. */
|
||||
export function calendarDateOf(instant: Date, zone: string): CalendarDate {
|
||||
return requireISODate(DateTime.fromJSDate(instant, { zone }), "calendarDateOf");
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine a calendar date + minutes-from-midnight (a business-local wall
|
||||
* clock time) into the correct UTC instant. DST-safe: Luxon resolves the
|
||||
* wall clock against the zone's actual offset for that specific date, not
|
||||
* a fixed offset — the same "10:00" on either side of a DST transition
|
||||
* produces different UTC instants, correctly.
|
||||
*/
|
||||
export function wallClockToInstant(date: CalendarDate, minutesFromMidnightValue: number, zone: string): Date {
|
||||
const dt = DateTime.fromISO(date, { zone }).startOf("day").plus({ minutes: minutesFromMidnightValue });
|
||||
return dt.toJSDate();
|
||||
}
|
||||
|
||||
export function todayIn(zone: string): CalendarDate {
|
||||
return requireISODate(DateTime.now().setZone(zone), "todayIn");
|
||||
}
|
||||
|
||||
/** The UTC instant at which `date` begins in `zone` — the inclusive lower bound for "appointments that fall on this business day". */
|
||||
export function startOfBusinessDay(date: CalendarDate, zone: string): Date {
|
||||
return wallClockToInstant(date, 0, zone);
|
||||
}
|
||||
|
||||
/** The UTC instant at which `date` ends in `zone` (= the start of the next day) — the exclusive upper bound for "appointments that fall on this business day". */
|
||||
export function endOfBusinessDay(date: CalendarDate, zone: string): Date {
|
||||
return wallClockToInstant(date, 24 * 60, zone);
|
||||
}
|
||||
|
||||
/**
|
||||
* `TimeOff`/date-only columns (`@db.Date`) are compared as UTC midnight
|
||||
* regardless of business time zone — a day off is the same calendar day
|
||||
* everywhere, it isn't a wall-clock-in-a-zone concept the way appointment
|
||||
* times are. Always go through this helper when reading or writing a
|
||||
* `@db.Date` column so the convention stays consistent both directions.
|
||||
*/
|
||||
export function calendarDateToUtcMidnight(date: CalendarDate): Date {
|
||||
return new Date(`${date}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
/** Pure calendar-date arithmetic — deliberately zone-independent (adding whole days never crosses a DST boundary ambiguity the way wall-clock-to-instant conversion can). */
|
||||
export function addDays(date: CalendarDate, days: number): CalendarDate {
|
||||
return requireISODate(DateTime.fromISO(date, { zone: "utc" }).plus({ days }), "addDays");
|
||||
}
|
||||
|
||||
export function compareCalendarDates(a: CalendarDate, b: CalendarDate): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
}
|
||||
|
||||
export function isBeforeCalendarDate(a: CalendarDate, b: CalendarDate): boolean {
|
||||
return compareCalendarDates(a, b) < 0;
|
||||
}
|
||||
|
||||
export function isAfterCalendarDate(a: CalendarDate, b: CalendarDate): boolean {
|
||||
return compareCalendarDates(a, b) > 0;
|
||||
}
|
||||
|
||||
export function diffMinutes(a: Date, b: Date): number {
|
||||
return (a.getTime() - b.getTime()) / 60_000;
|
||||
}
|
||||
|
||||
/** Every CalendarDate from `from` to `to` inclusive. Throws if the range is absurdly large, as a guard against a caller accidentally passing unbounded input. */
|
||||
export function* eachCalendarDate(from: CalendarDate, to: CalendarDate): Generator<CalendarDate> {
|
||||
if (isAfterCalendarDate(from, to)) return;
|
||||
let cursor = from;
|
||||
let guard = 0;
|
||||
while (!isAfterCalendarDate(cursor, to)) {
|
||||
if (++guard > 366) throw new Error("eachCalendarDate: range exceeds 366 days");
|
||||
yield cursor;
|
||||
cursor = addDays(cursor, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AppointmentStatus } from "@/generated/prisma/enums";
|
||||
import { canTransition, validateTransition } from "../appointment.service";
|
||||
|
||||
const ALL_STATUSES = Object.values(AppointmentStatus);
|
||||
|
||||
describe("canTransition", () => {
|
||||
it("allows the documented happy path", () => {
|
||||
expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.CONFIRMED)).toBe(true);
|
||||
expect(canTransition(AppointmentStatus.CONFIRMED, AppointmentStatus.CHECKED_IN)).toBe(true);
|
||||
expect(canTransition(AppointmentStatus.CHECKED_IN, AppointmentStatus.IN_PROGRESS)).toBe(true);
|
||||
expect(canTransition(AppointmentStatus.IN_PROGRESS, AppointmentStatus.COMPLETED)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows cancellation from PENDING, CONFIRMED, and CHECKED_IN", () => {
|
||||
expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.CANCELLED)).toBe(true);
|
||||
expect(canTransition(AppointmentStatus.CONFIRMED, AppointmentStatus.CANCELLED)).toBe(true);
|
||||
expect(canTransition(AppointmentStatus.CHECKED_IN, AppointmentStatus.CANCELLED)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows NO_SHOW only from CONFIRMED — once checked in, the customer clearly did show up", () => {
|
||||
expect(canTransition(AppointmentStatus.CONFIRMED, AppointmentStatus.NO_SHOW)).toBe(true);
|
||||
expect(canTransition(AppointmentStatus.CHECKED_IN, AppointmentStatus.NO_SHOW)).toBe(false);
|
||||
expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.NO_SHOW)).toBe(false);
|
||||
expect(canTransition(AppointmentStatus.IN_PROGRESS, AppointmentStatus.NO_SHOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats COMPLETED, CANCELLED, and NO_SHOW as terminal — no transitions out", () => {
|
||||
for (const terminal of [AppointmentStatus.COMPLETED, AppointmentStatus.CANCELLED, AppointmentStatus.NO_SHOW]) {
|
||||
for (const target of ALL_STATUSES) {
|
||||
expect(canTransition(terminal, target)).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("never allows a no-op transition to the same status", () => {
|
||||
for (const status of ALL_STATUSES) {
|
||||
expect(canTransition(status, status)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("disallows skipping straight from PENDING to CHECKED_IN or later", () => {
|
||||
expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.CHECKED_IN)).toBe(false);
|
||||
expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.IN_PROGRESS)).toBe(false);
|
||||
expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.COMPLETED)).toBe(false);
|
||||
});
|
||||
|
||||
it("disallows going backwards", () => {
|
||||
expect(canTransition(AppointmentStatus.IN_PROGRESS, AppointmentStatus.CHECKED_IN)).toBe(false);
|
||||
expect(canTransition(AppointmentStatus.CHECKED_IN, AppointmentStatus.CONFIRMED)).toBe(false);
|
||||
expect(canTransition(AppointmentStatus.COMPLETED, AppointmentStatus.IN_PROGRESS)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTransition", () => {
|
||||
it("returns a valid result for an allowed transition", () => {
|
||||
const result = validateTransition(AppointmentStatus.CONFIRMED, AppointmentStatus.CHECKED_IN);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.issues).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns a specific error code for a disallowed transition", () => {
|
||||
const result = validateTransition(AppointmentStatus.COMPLETED, AppointmentStatus.CANCELLED);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_STATUS_TRANSITION");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,600 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
import { getAvailableDates, getAvailableStaff, getAvailableTimeSlots, validateBookingSlot } from "../availability.service";
|
||||
import { cancelBooking, createBooking, rescheduleBooking } from "../booking.service";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestBusiness,
|
||||
createTestCustomer,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "./fixtures";
|
||||
import type { TestBusiness } from "./fixtures";
|
||||
|
||||
/** The next date (at least `minDaysAhead` out) that falls on `target` — avoids hardcoding a calendar date that could go stale or land on a closed day depending on when the suite runs. */
|
||||
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
|
||||
let date = addDays(todayIn(timezone), minDaysAhead);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (weekdayOf(date) === target) return date;
|
||||
date = addDays(date, 1);
|
||||
}
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
describe("booking engine (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
let monday: CalendarDate;
|
||||
let sunday: CalendarDate;
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
monday = nextWeekday(business.timezone, Weekday.MONDAY);
|
||||
sunday = addDays(monday, 6); // the business is closed on Sunday — see fixtures.ts
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
describe("availability calculation", () => {
|
||||
it("offers slots for a working staff member on a working day", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
|
||||
const { slots, validation } = await getAvailableTimeSlots(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
date: monday,
|
||||
});
|
||||
|
||||
expect(validation.valid).toBe(true);
|
||||
expect(slots.length).toBeGreaterThan(0);
|
||||
expect(slots[0]!.staffOptions).toContain(staffId);
|
||||
// 09:00 business open, 30 min slot interval — the very first slot should be 09:00.
|
||||
expect(slots[0]!.startMinutes).toBe(9 * 60);
|
||||
});
|
||||
|
||||
it("lists a date as available only if it actually has openings", async () => {
|
||||
await createTestStaff(business);
|
||||
|
||||
const { dates } = await getAvailableDates(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
fromDate: monday,
|
||||
toDate: sunday,
|
||||
});
|
||||
|
||||
expect(dates).toContain(monday);
|
||||
expect(dates).not.toContain(sunday); // business closed Sundays
|
||||
});
|
||||
|
||||
it("getAvailableStaff only returns staff who can perform every requested service", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
// A second staff member who can only do haircuts, not beard trims.
|
||||
const role = await prisma.role.create({ data: { businessId: business.businessId, name: "Junior" } });
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `junior-${Date.now()}@test.local`,
|
||||
staffProfile: {
|
||||
create: {
|
||||
businessId: business.businessId,
|
||||
roleId: role.id,
|
||||
services: { create: [{ serviceId: business.haircutServiceId }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { staffProfile: true },
|
||||
});
|
||||
business.createdUserIds.push(user.id);
|
||||
|
||||
const staffForHaircutOnly = await getAvailableStaff(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
});
|
||||
const staffIds = staffForHaircutOnly.map((s) => s.id);
|
||||
expect(staffIds).toContain(staffId);
|
||||
expect(staffIds).toContain(user.staffProfile!.id);
|
||||
|
||||
const staffForBoth = await getAvailableStaff(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId, business.beardTrimServiceId],
|
||||
});
|
||||
const bothIds = staffForBoth.map((s) => s.id);
|
||||
expect(bothIds).toContain(staffId);
|
||||
expect(bothIds).not.toContain(user.staffProfile!.id); // junior can't do beard trims
|
||||
});
|
||||
});
|
||||
|
||||
describe("business closed", () => {
|
||||
it("offers no slots on a day the business is closed", async () => {
|
||||
await createTestStaff(business);
|
||||
|
||||
const { slots } = await getAvailableTimeSlots(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
date: sunday,
|
||||
});
|
||||
|
||||
expect(slots).toEqual([]);
|
||||
});
|
||||
|
||||
it("validateBookingSlot reports BUSINESS_CLOSED for a Sunday booking attempt", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const startAt = wallClockToInstant(sunday, 10 * 60, business.timezone);
|
||||
|
||||
const { validation } = await validateBookingSlot(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
staffId,
|
||||
startAt,
|
||||
});
|
||||
|
||||
expect(validation.valid).toBe(false);
|
||||
expect(validation.issues.map((i) => i.code)).toContain("BUSINESS_CLOSED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("staff working hours", () => {
|
||||
it("STAFF_NOT_WORKING when the staff member doesn't work that day, even though the business is open", async () => {
|
||||
const { staffId } = await createTestStaff(business, { workingDays: [Weekday.TUESDAY] }); // never works Monday
|
||||
const startAt = wallClockToInstant(monday, 10 * 60, business.timezone);
|
||||
|
||||
const { validation } = await validateBookingSlot(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
staffId,
|
||||
startAt,
|
||||
});
|
||||
|
||||
expect(validation.valid).toBe(false);
|
||||
expect(validation.issues.map((i) => i.code)).toContain("STAFF_NOT_WORKING");
|
||||
});
|
||||
});
|
||||
|
||||
describe("staff breaks", () => {
|
||||
it("excludes a lunch break from available slots and flags a candidate inside it", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const lunchBreak = await prisma.break.create({
|
||||
data: {
|
||||
staffId,
|
||||
label: "Lunch",
|
||||
startMinutes: 12 * 60,
|
||||
endMinutes: 13 * 60,
|
||||
days: { create: [{ dayOfWeek: Weekday.MONDAY }] },
|
||||
},
|
||||
});
|
||||
|
||||
const { slots } = await getAvailableTimeSlots(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
date: monday,
|
||||
});
|
||||
const duringLunch = slots.some((s) => s.startMinutes >= 12 * 60 && s.startMinutes < 13 * 60);
|
||||
expect(duringLunch).toBe(false);
|
||||
|
||||
const { validation } = await validateBookingSlot(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
staffId,
|
||||
startAt: wallClockToInstant(monday, 12 * 60 + 15, business.timezone),
|
||||
});
|
||||
expect(validation.valid).toBe(false);
|
||||
expect(validation.issues.map((i) => i.code)).toContain("STAFF_ON_BREAK");
|
||||
|
||||
await prisma.break.delete({ where: { id: lunchBreak.id } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("time off", () => {
|
||||
it("blocks the whole day when the staff member has approved leave", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
await prisma.timeOff.create({
|
||||
data: {
|
||||
staffId,
|
||||
startDate: new Date(`${monday}T00:00:00.000Z`),
|
||||
endDate: new Date(`${monday}T00:00:00.000Z`),
|
||||
status: "APPROVED",
|
||||
},
|
||||
});
|
||||
|
||||
const { slots } = await getAvailableTimeSlots(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
date: monday,
|
||||
});
|
||||
expect(slots).toEqual([]);
|
||||
|
||||
const { validation } = await validateBookingSlot(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
staffId,
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
expect(validation.issues.map((i) => i.code)).toContain("STAFF_ON_LEAVE");
|
||||
});
|
||||
|
||||
it("does not block a day the leave doesn't cover", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const tuesday = addDays(monday, 1);
|
||||
await prisma.timeOff.create({
|
||||
data: {
|
||||
staffId,
|
||||
startDate: new Date(`${tuesday}T00:00:00.000Z`),
|
||||
endDate: new Date(`${tuesday}T00:00:00.000Z`),
|
||||
status: "APPROVED",
|
||||
},
|
||||
});
|
||||
|
||||
const { slots } = await getAvailableTimeSlots(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
date: monday,
|
||||
});
|
||||
expect(slots.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple services", () => {
|
||||
it("combines durations and requires a window long enough for all requested services", async () => {
|
||||
await createTestStaff(business);
|
||||
|
||||
const { slots: singleService } = await getAvailableTimeSlots(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId], // 30 min
|
||||
date: monday,
|
||||
});
|
||||
const { slots: combined } = await getAvailableTimeSlots(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId, business.beardTrimServiceId], // 30 + 15 = 45 min
|
||||
date: monday,
|
||||
});
|
||||
|
||||
expect(combined[0]!.endMinutes - combined[0]!.startMinutes).toBe(45);
|
||||
// The combined-duration slot list can't offer more start times deep near closing than the shorter single-service one.
|
||||
const lastSingle = singleService[singleService.length - 1]!.startMinutes;
|
||||
const lastCombined = combined[combined.length - 1]!.startMinutes;
|
||||
expect(lastCombined).toBeLessThanOrEqual(lastSingle);
|
||||
});
|
||||
|
||||
it("creates one AppointmentService row per requested service, with price/duration snapshotted", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
const result = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId, business.beardTrimServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect(result.appointment.services).toHaveLength(2);
|
||||
const total = result.appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
|
||||
expect(total).toBe(30); // £20 haircut + £10 beard trim
|
||||
const durationTotal = result.appointment.services.reduce((sum, s) => sum + s.durationAtBookingMinutes, 0);
|
||||
expect(durationTotal).toBe(45);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buffer times", () => {
|
||||
it("blocks a slot that would start inside another appointment's buffer", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
// Give this business a 15-minute buffer after every appointment.
|
||||
await prisma.bookingRule.update({
|
||||
where: { businessId: business.businessId },
|
||||
data: { bufferAfterMinutes: 15 },
|
||||
});
|
||||
|
||||
const first = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId], // 09:00–09:30
|
||||
startAt: wallClockToInstant(monday, 9 * 60, business.timezone),
|
||||
});
|
||||
expect(first.success).toBe(true);
|
||||
|
||||
// 09:30 is technically free of the first appointment itself, but inside its 15-min buffer.
|
||||
const { validation } = await validateBookingSlot(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
staffId,
|
||||
startAt: wallClockToInstant(monday, 9 * 60 + 30, business.timezone),
|
||||
});
|
||||
expect(validation.valid).toBe(false);
|
||||
expect(validation.issues.map((i) => i.code)).toContain("SLOT_UNAVAILABLE");
|
||||
|
||||
// 09:45 is clear of the buffer.
|
||||
const { validation: laterValidation } = await validateBookingSlot(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
staffId,
|
||||
startAt: wallClockToInstant(monday, 9 * 60 + 45, business.timezone),
|
||||
});
|
||||
expect(laterValidation.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("booking validation", () => {
|
||||
it("rejects a booking below the minimum notice period", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { minNoticeMinutes: 120 } });
|
||||
|
||||
const { validation } = await validateBookingSlot(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
staffId,
|
||||
startAt: new Date(Date.now() + 5 * 60_000), // 5 minutes from now
|
||||
});
|
||||
|
||||
expect(validation.valid).toBe(false);
|
||||
expect(validation.issues.map((i) => i.code)).toContain("BELOW_MIN_NOTICE");
|
||||
});
|
||||
|
||||
it("rejects a booking beyond the maximum advance window", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { maxAdvanceDays: 7 } });
|
||||
|
||||
const farFuture = addDays(todayIn(business.timezone), 60);
|
||||
const { validation } = await validateBookingSlot(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
staffId,
|
||||
startAt: wallClockToInstant(farFuture, 10 * 60, business.timezone),
|
||||
});
|
||||
|
||||
expect(validation.valid).toBe(false);
|
||||
expect(validation.issues.map((i) => i.code)).toContain("BEYOND_MAX_ADVANCE");
|
||||
});
|
||||
|
||||
it("rejects once the business's daily appointment cap is reached", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { maxAppointmentsPerDay: 1 } });
|
||||
|
||||
const first = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 9 * 60, business.timezone),
|
||||
});
|
||||
expect(first.success).toBe(true);
|
||||
|
||||
const second = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 11 * 60, business.timezone),
|
||||
});
|
||||
expect(second.success).toBe(false);
|
||||
if (second.success) return;
|
||||
expect(second.validation.issues.map((i) => i.code)).toContain("BUSINESS_DAILY_LIMIT_REACHED");
|
||||
});
|
||||
|
||||
it("rejects services that don't belong to the business", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { validation } = await validateBookingSlot(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: ["not-a-real-service-id"],
|
||||
staffId,
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
expect(validation.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("double booking prevention", () => {
|
||||
it("rejects a second booking for the exact same staff/time", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const startAt = wallClockToInstant(monday, 10 * 60, business.timezone);
|
||||
|
||||
const first = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt,
|
||||
});
|
||||
const second = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt,
|
||||
});
|
||||
|
||||
expect(first.success).toBe(true);
|
||||
expect(second.success).toBe(false);
|
||||
if (second.success) return;
|
||||
expect(second.validation.issues.map((i) => i.code)).toContain("SLOT_UNAVAILABLE");
|
||||
});
|
||||
|
||||
it("under real concurrency, exactly one of two simultaneous requests for the same slot succeeds", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const startAt = wallClockToInstant(monday, 14 * 60, business.timezone);
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt,
|
||||
}),
|
||||
createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt,
|
||||
}),
|
||||
]);
|
||||
|
||||
const successes = [a, b].filter((r) => r.success);
|
||||
const failures = [a, b].filter((r) => !r.success);
|
||||
expect(successes).toHaveLength(1);
|
||||
expect(failures).toHaveLength(1);
|
||||
|
||||
const appointmentCount = await prisma.appointment.count({
|
||||
where: { businessId: business.businessId, staffId, status: { notIn: ["CANCELLED", "NO_SHOW"] } },
|
||||
});
|
||||
expect(appointmentCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rescheduling", () => {
|
||||
it("moves the appointment to the new time and applies the same validation as a new booking", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
const created = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
if (!created.success) return;
|
||||
|
||||
const rescheduled = await rescheduleBooking(prisma, {
|
||||
appointmentId: created.appointment.id,
|
||||
startAt: wallClockToInstant(monday, 15 * 60, business.timezone),
|
||||
});
|
||||
expect(rescheduled.success).toBe(true);
|
||||
if (!rescheduled.success) return;
|
||||
expect(rescheduled.appointment.id).toBe(created.appointment.id);
|
||||
expect(rescheduled.appointment.startAt.toISOString()).toBe(
|
||||
wallClockToInstant(monday, 15 * 60, business.timezone).toISOString(),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not conflict with itself when rescheduled to overlap its own original time", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
const created = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
if (!created.success) return;
|
||||
|
||||
// "Reschedule" to 10 minutes later — overlaps its own current slot, must not be treated as a conflict with itself.
|
||||
const rescheduled = await rescheduleBooking(prisma, {
|
||||
appointmentId: created.appointment.id,
|
||||
startAt: wallClockToInstant(monday, 10 * 60 + 10, business.timezone),
|
||||
});
|
||||
expect(rescheduled.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects rescheduling a completed appointment", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
const created = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
if (!created.success) throw new Error("setup failed");
|
||||
await prisma.appointment.update({ where: { id: created.appointment.id }, data: { status: "COMPLETED" } });
|
||||
|
||||
const rescheduled = await rescheduleBooking(prisma, {
|
||||
appointmentId: created.appointment.id,
|
||||
startAt: wallClockToInstant(monday, 15 * 60, business.timezone),
|
||||
});
|
||||
expect(rescheduled.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancellation", () => {
|
||||
it("never deletes — sets status, cancelledAt, cancelledByUserId, and reason", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId, userId: customerUserId } = await createTestCustomer(business);
|
||||
|
||||
const created = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
if (!created.success) throw new Error("setup failed");
|
||||
|
||||
const result = await cancelBooking(prisma, {
|
||||
appointmentId: created.appointment.id,
|
||||
cancelledByUserId: customerUserId,
|
||||
reason: "Change of plans",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.appointment?.status).toBe("CANCELLED");
|
||||
expect(result.appointment?.cancelledByUserId).toBe(customerUserId);
|
||||
expect(result.appointment?.cancellationReason).toBe("Change of plans");
|
||||
expect(result.appointment?.cancelledAt).toBeInstanceOf(Date);
|
||||
|
||||
// Still in the database — not deleted.
|
||||
const stillExists = await prisma.appointment.findUnique({ where: { id: created.appointment.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
it("frees the slot up for a new booking", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId, userId: customerUserId } = await createTestCustomer(business);
|
||||
const startAt = wallClockToInstant(monday, 10 * 60, business.timezone);
|
||||
|
||||
const created = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt,
|
||||
});
|
||||
if (!created.success) throw new Error("setup failed");
|
||||
|
||||
await cancelBooking(prisma, { appointmentId: created.appointment.id, cancelledByUserId: customerUserId });
|
||||
|
||||
const rebooked = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt,
|
||||
});
|
||||
expect(rebooked.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects cancelling an already-completed appointment", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId, userId: customerUserId } = await createTestCustomer(business);
|
||||
|
||||
const created = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
if (!created.success) throw new Error("setup failed");
|
||||
await prisma.appointment.update({ where: { id: created.appointment.id }, data: { status: "COMPLETED" } });
|
||||
|
||||
const result = await cancelBooking(prisma, { appointmentId: created.appointment.id, cancelledByUserId: customerUserId });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
checkBusinessDailyLimit,
|
||||
checkMaxAdvance,
|
||||
checkMinNotice,
|
||||
checkStaffDailyLimit,
|
||||
combinedServiceDuration,
|
||||
computeEffectiveBuffer,
|
||||
isLateCancellation,
|
||||
isLateReschedule,
|
||||
} from "../business-rules.service";
|
||||
|
||||
describe("checkMinNotice", () => {
|
||||
const now = new Date("2026-08-10T10:00:00Z");
|
||||
|
||||
it("fails when the candidate start is too soon", () => {
|
||||
const candidate = new Date("2026-08-10T10:30:00Z"); // 30 min from now
|
||||
const result = checkMinNotice(candidate, now, 120);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("BELOW_MIN_NOTICE");
|
||||
});
|
||||
|
||||
it("passes when the candidate start is far enough ahead", () => {
|
||||
const candidate = new Date("2026-08-10T13:00:00Z"); // 3h from now
|
||||
expect(checkMinNotice(candidate, now, 120).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("passes exactly at the boundary", () => {
|
||||
const candidate = new Date("2026-08-10T12:00:00Z"); // exactly 120 min
|
||||
expect(checkMinNotice(candidate, now, 120).valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkMaxAdvance", () => {
|
||||
it("fails when the date is beyond the max advance window", () => {
|
||||
const result = checkMaxAdvance("2026-11-01", "2026-08-10", 60);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("BEYOND_MAX_ADVANCE");
|
||||
});
|
||||
|
||||
it("passes when the date is within the window", () => {
|
||||
expect(checkMaxAdvance("2026-08-20", "2026-08-10", 60).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("passes exactly at the boundary", () => {
|
||||
expect(checkMaxAdvance("2026-10-09", "2026-08-10", 60).valid).toBe(true); // exactly 60 days
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkBusinessDailyLimit / checkStaffDailyLimit", () => {
|
||||
it("null limit means unlimited", () => {
|
||||
expect(checkBusinessDailyLimit(999, null).valid).toBe(true);
|
||||
expect(checkStaffDailyLimit(999, null).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("fails once the existing count reaches the limit", () => {
|
||||
expect(checkBusinessDailyLimit(10, 10).valid).toBe(false);
|
||||
expect(checkStaffDailyLimit(5, 5).valid).toBe(false);
|
||||
});
|
||||
|
||||
it("passes below the limit", () => {
|
||||
expect(checkBusinessDailyLimit(9, 10).valid).toBe(true);
|
||||
expect(checkStaffDailyLimit(4, 5).valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEffectiveBuffer", () => {
|
||||
it("sums the business baseline with the largest per-service buffer", () => {
|
||||
const result = computeEffectiveBuffer(
|
||||
{ bufferBeforeMinutes: 5, bufferAfterMinutes: 5 },
|
||||
[
|
||||
{ bufferBeforeMinutes: 0, bufferAfterMinutes: 15 }, // e.g. colour treatment
|
||||
{ bufferBeforeMinutes: 0, bufferAfterMinutes: 0 },
|
||||
],
|
||||
);
|
||||
expect(result).toEqual({ beforeMinutes: 5, afterMinutes: 20 });
|
||||
});
|
||||
|
||||
it("returns just the business baseline when no service adds a buffer", () => {
|
||||
expect(computeEffectiveBuffer({ bufferBeforeMinutes: 0, bufferAfterMinutes: 5 }, [])).toEqual({
|
||||
beforeMinutes: 0,
|
||||
afterMinutes: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLateCancellation / isLateReschedule", () => {
|
||||
const now = new Date("2026-08-10T10:00:00Z");
|
||||
|
||||
it("flags a cancellation inside the free window as late", () => {
|
||||
const appointmentStart = new Date("2026-08-10T20:00:00Z"); // 10h away, window is 24h
|
||||
expect(isLateCancellation(appointmentStart, now, 24)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag a cancellation outside the free window", () => {
|
||||
const appointmentStart = new Date("2026-08-12T10:00:00Z"); // 48h away
|
||||
expect(isLateCancellation(appointmentStart, now, 24)).toBe(false);
|
||||
});
|
||||
|
||||
it("applies the same logic to reschedules with their own window", () => {
|
||||
const appointmentStart = new Date("2026-08-10T11:00:00Z"); // 1h away, window is 24h
|
||||
expect(isLateReschedule(appointmentStart, now, 24)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combinedServiceDuration", () => {
|
||||
it("sums durations across multiple services", () => {
|
||||
expect(combinedServiceDuration([{ durationMinutes: 30 }, { durationMinutes: 15 }])).toBe(45);
|
||||
});
|
||||
|
||||
it("returns 0 for an empty list", () => {
|
||||
expect(combinedServiceDuration([])).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import "dotenv/config";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* Integration-test fixtures — real Postgres, real transactions. Not used
|
||||
* by the unit test suite (`npm test`); only by `npm run test:integration`.
|
||||
* See docs/phase-3-summary.md for how to point this at a database.
|
||||
*/
|
||||
|
||||
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
||||
export const prisma = new PrismaClient({ adapter });
|
||||
|
||||
let counter = 0;
|
||||
function uniqueId(prefix: string): string {
|
||||
counter += 1;
|
||||
return `${prefix}-${Date.now()}-${counter}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
export interface TestBusiness {
|
||||
businessId: string;
|
||||
timezone: string;
|
||||
categoryId: string;
|
||||
haircutServiceId: string;
|
||||
beardTrimServiceId: string;
|
||||
createdUserIds: string[];
|
||||
}
|
||||
|
||||
/** Mon–Sat 09:00–18:00, Sunday closed. Booking rule defaults are deliberately permissive (no min notice, no daily caps) so most tests don't have to fight them — tests that specifically exercise a rule override it. */
|
||||
export async function createTestBusiness(): Promise<TestBusiness> {
|
||||
const slug = uniqueId("test-biz");
|
||||
const timezone = "Europe/London";
|
||||
|
||||
const business = await prisma.business.create({
|
||||
data: {
|
||||
slug,
|
||||
name: "Test Barbershop",
|
||||
timezone,
|
||||
hours: {
|
||||
create: [
|
||||
{ dayOfWeek: Weekday.MONDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
|
||||
{ dayOfWeek: Weekday.TUESDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
|
||||
{ dayOfWeek: Weekday.WEDNESDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
|
||||
{ dayOfWeek: Weekday.THURSDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
|
||||
{ dayOfWeek: Weekday.FRIDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
|
||||
{ dayOfWeek: Weekday.SATURDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
|
||||
{ dayOfWeek: Weekday.SUNDAY, isClosed: true },
|
||||
],
|
||||
},
|
||||
bookingRule: {
|
||||
create: {
|
||||
minNoticeMinutes: 0,
|
||||
maxAdvanceDays: 365,
|
||||
slotIntervalMinutes: 30,
|
||||
bufferBeforeMinutes: 0,
|
||||
bufferAfterMinutes: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const category = await prisma.serviceCategory.create({
|
||||
data: { businessId: business.id, name: "Hair" },
|
||||
});
|
||||
|
||||
const haircut = await prisma.service.create({
|
||||
data: {
|
||||
businessId: business.id,
|
||||
categoryId: category.id,
|
||||
name: "Haircut",
|
||||
durationMinutes: 30,
|
||||
price: 20,
|
||||
},
|
||||
});
|
||||
|
||||
const beardTrim = await prisma.service.create({
|
||||
data: {
|
||||
businessId: business.id,
|
||||
categoryId: category.id,
|
||||
name: "Beard Trim",
|
||||
durationMinutes: 15,
|
||||
price: 10,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
businessId: business.id,
|
||||
timezone,
|
||||
categoryId: category.id,
|
||||
haircutServiceId: haircut.id,
|
||||
beardTrimServiceId: beardTrim.id,
|
||||
createdUserIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** A staff member who works Mon–Sat 09:00–18:00 (matching the business), can perform both fixture services. */
|
||||
export async function createTestStaff(
|
||||
business: TestBusiness,
|
||||
opts: { workingDays?: Weekday[] } = {},
|
||||
): Promise<{ staffId: string; userId: string }> {
|
||||
const workingDays = opts.workingDays ?? [
|
||||
Weekday.MONDAY,
|
||||
Weekday.TUESDAY,
|
||||
Weekday.WEDNESDAY,
|
||||
Weekday.THURSDAY,
|
||||
Weekday.FRIDAY,
|
||||
Weekday.SATURDAY,
|
||||
];
|
||||
|
||||
const role = await prisma.role.create({
|
||||
data: { businessId: business.businessId, name: uniqueId("Barber") },
|
||||
});
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `${uniqueId("staff")}@test.local`,
|
||||
name: "Test Barber",
|
||||
staffProfile: {
|
||||
create: {
|
||||
businessId: business.businessId,
|
||||
roleId: role.id,
|
||||
workingHours: {
|
||||
create: Object.values(Weekday).map((dayOfWeek) =>
|
||||
workingDays.includes(dayOfWeek)
|
||||
? { dayOfWeek, startMinutes: 9 * 60, endMinutes: 18 * 60 }
|
||||
: { dayOfWeek, isOff: true },
|
||||
),
|
||||
},
|
||||
services: {
|
||||
create: [{ serviceId: business.haircutServiceId }, { serviceId: business.beardTrimServiceId }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { staffProfile: true },
|
||||
});
|
||||
|
||||
business.createdUserIds.push(user.id);
|
||||
return { staffId: user.staffProfile!.id, userId: user.id };
|
||||
}
|
||||
|
||||
export async function createTestCustomer(business: TestBusiness): Promise<{ customerId: string; userId: string }> {
|
||||
const email = `${uniqueId("customer")}@test.local`;
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: "Test Customer",
|
||||
customerProfile: {
|
||||
create: { firstName: "Test", lastName: "Customer", email, isGuest: false },
|
||||
},
|
||||
},
|
||||
include: { customerProfile: true },
|
||||
});
|
||||
business.createdUserIds.push(user.id);
|
||||
return { customerId: user.customerProfile!.id, userId: user.id };
|
||||
}
|
||||
|
||||
export async function cleanupTestBusiness(business: TestBusiness): Promise<void> {
|
||||
await prisma.business.delete({ where: { id: business.businessId } }).catch(() => {});
|
||||
if (business.createdUserIds.length > 0) {
|
||||
await prisma.user.deleteMany({ where: { id: { in: business.createdUserIds } } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeFreeWindows,
|
||||
expandWithBuffer,
|
||||
generateSlotStarts,
|
||||
intersectWindows,
|
||||
mergeWindows,
|
||||
subtractWindows,
|
||||
} from "../slot-calculator";
|
||||
import type { TimeWindow } from "../types";
|
||||
|
||||
const w = (startMinutes: number, endMinutes: number): TimeWindow => ({ startMinutes, endMinutes });
|
||||
|
||||
describe("mergeWindows", () => {
|
||||
it("merges overlapping and touching windows, drops invalid/zero-length ones", () => {
|
||||
expect(mergeWindows([w(0, 60), w(30, 90), w(90, 120)])).toEqual([w(0, 120)]);
|
||||
expect(mergeWindows([w(0, 30), w(40, 60)])).toEqual([w(0, 30), w(40, 60)]);
|
||||
expect(mergeWindows([w(10, 10), w(20, 15)])).toEqual([]); // zero-length / inverted dropped
|
||||
});
|
||||
|
||||
it("sorts unordered input before merging", () => {
|
||||
expect(mergeWindows([w(100, 120), w(0, 30)])).toEqual([w(0, 30), w(100, 120)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subtractWindows", () => {
|
||||
it("removes a window entirely contained within a base window, splitting it in two", () => {
|
||||
expect(subtractWindows([w(0, 120)], [w(30, 60)])).toEqual([w(0, 30), w(60, 120)]);
|
||||
});
|
||||
|
||||
it("removes from the start and end", () => {
|
||||
expect(subtractWindows([w(0, 120)], [w(0, 30)])).toEqual([w(30, 120)]);
|
||||
expect(subtractWindows([w(0, 120)], [w(90, 120)])).toEqual([w(0, 90)]);
|
||||
});
|
||||
|
||||
it("removes the whole window when the cut fully covers it", () => {
|
||||
expect(subtractWindows([w(30, 60)], [w(0, 120)])).toEqual([]);
|
||||
});
|
||||
|
||||
it("no-ops when there's no overlap", () => {
|
||||
expect(subtractWindows([w(0, 60)], [w(60, 120)])).toEqual([w(0, 60)]);
|
||||
expect(subtractWindows([w(60, 120)], [w(0, 60)])).toEqual([w(60, 120)]);
|
||||
});
|
||||
|
||||
it("applies multiple cuts, including overlapping ones", () => {
|
||||
expect(subtractWindows([w(0, 480)], [w(60, 90), w(70, 120), w(300, 330)])).toEqual([
|
||||
w(0, 60),
|
||||
w(120, 300),
|
||||
w(330, 480),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("intersectWindows", () => {
|
||||
it("intersects business hours with staff hours", () => {
|
||||
expect(intersectWindows([w(600, 1140)], [w(540, 1080)])).toEqual([w(600, 1080)]);
|
||||
});
|
||||
|
||||
it("returns nothing when there's no overlap", () => {
|
||||
expect(intersectWindows([w(0, 60)], [w(120, 180)])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeFreeWindows", () => {
|
||||
const fullDayOpen = w(9 * 60, 18 * 60); // 09:00–18:00
|
||||
|
||||
it("returns nothing if the business is closed", () => {
|
||||
expect(
|
||||
computeFreeWindows({ businessOpen: null, staffWorking: fullDayOpen, breaks: [], onLeave: false, busy: [] }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns nothing if the staff member isn't scheduled to work", () => {
|
||||
expect(
|
||||
computeFreeWindows({ businessOpen: fullDayOpen, staffWorking: null, breaks: [], onLeave: false, busy: [] }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns nothing if the staff member is on approved leave, even if otherwise free", () => {
|
||||
expect(
|
||||
computeFreeWindows({
|
||||
businessOpen: fullDayOpen,
|
||||
staffWorking: fullDayOpen,
|
||||
breaks: [],
|
||||
onLeave: true,
|
||||
busy: [],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("intersects business and staff hours when staff works shorter hours", () => {
|
||||
const result = computeFreeWindows({
|
||||
businessOpen: w(9 * 60, 18 * 60),
|
||||
staffWorking: w(10 * 60, 16 * 60),
|
||||
breaks: [],
|
||||
onLeave: false,
|
||||
busy: [],
|
||||
});
|
||||
expect(result).toEqual([w(10 * 60, 16 * 60)]);
|
||||
});
|
||||
|
||||
it("subtracts a lunch break", () => {
|
||||
const result = computeFreeWindows({
|
||||
businessOpen: fullDayOpen,
|
||||
staffWorking: fullDayOpen,
|
||||
breaks: [w(12 * 60, 13 * 60)],
|
||||
onLeave: false,
|
||||
busy: [],
|
||||
});
|
||||
expect(result).toEqual([w(9 * 60, 12 * 60), w(13 * 60, 18 * 60)]);
|
||||
});
|
||||
|
||||
it("subtracts buffered busy time from existing appointments", () => {
|
||||
const result = computeFreeWindows({
|
||||
businessOpen: fullDayOpen,
|
||||
staffWorking: fullDayOpen,
|
||||
breaks: [],
|
||||
onLeave: false,
|
||||
busy: [w(10 * 60 - 5, 10 * 60 + 45 + 5)], // 10:00–10:45 appointment with 5min buffer each side
|
||||
});
|
||||
expect(result).toEqual([w(9 * 60, 10 * 60 - 5), w(10 * 60 + 50, 18 * 60)]);
|
||||
});
|
||||
|
||||
it("combines breaks and busy time in one pass", () => {
|
||||
const result = computeFreeWindows({
|
||||
businessOpen: fullDayOpen,
|
||||
staffWorking: fullDayOpen,
|
||||
breaks: [w(12 * 60, 13 * 60)],
|
||||
onLeave: false,
|
||||
busy: [w(9 * 60, 9 * 60 + 30)],
|
||||
});
|
||||
expect(result).toEqual([w(9 * 60 + 30, 12 * 60), w(13 * 60, 18 * 60)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateSlotStarts", () => {
|
||||
it("generates grid-aligned starts that fit the requested duration", () => {
|
||||
// 09:00–10:00 window, 30 min slots, 30 min service → 09:00 and 09:30 only.
|
||||
expect(generateSlotStarts([w(540, 600)], { slotIntervalMinutes: 30, durationMinutes: 30 })).toEqual([540, 570]);
|
||||
});
|
||||
|
||||
it("excludes a start that would run past the end of the window", () => {
|
||||
// 09:00–09:45 window, 30 min slots, 30 min service → only 09:00 fits (09:30+30=10:00 doesn't).
|
||||
expect(generateSlotStarts([w(540, 585)], { slotIntervalMinutes: 30, durationMinutes: 30 })).toEqual([540]);
|
||||
});
|
||||
|
||||
it("aligns to the global grid, not the window's own start", () => {
|
||||
// Window starts at 09:05 (e.g. after a short buffer); with a 30-min grid the first candidate is 09:30, not 09:05.
|
||||
expect(generateSlotStarts([w(545, 630)], { slotIntervalMinutes: 30, durationMinutes: 30 })).toEqual([570, 600]);
|
||||
});
|
||||
|
||||
it("handles a multi-service duration longer than the slot interval", () => {
|
||||
// 09:00–10:30, 15-min grid, 45-min combined service duration.
|
||||
const starts = generateSlotStarts([w(540, 630)], { slotIntervalMinutes: 15, durationMinutes: 45 });
|
||||
expect(starts).toEqual([540, 555, 570, 585]); // last start 09:45 -> ends 10:30 exactly
|
||||
});
|
||||
|
||||
it("returns nothing when the window is shorter than the requested duration", () => {
|
||||
expect(generateSlotStarts([w(540, 560)], { slotIntervalMinutes: 15, durationMinutes: 30 })).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects non-positive interval or duration", () => {
|
||||
expect(() => generateSlotStarts([w(0, 60)], { slotIntervalMinutes: 0, durationMinutes: 30 })).toThrow();
|
||||
expect(() => generateSlotStarts([w(0, 60)], { slotIntervalMinutes: 30, durationMinutes: 0 })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandWithBuffer", () => {
|
||||
it("pads both sides", () => {
|
||||
expect(expandWithBuffer(w(600, 630), { beforeMinutes: 5, afterMinutes: 10 })).toEqual(w(595, 640));
|
||||
});
|
||||
|
||||
it("clamps at day boundaries instead of going negative or past 1440", () => {
|
||||
expect(expandWithBuffer(w(0, 30), { beforeMinutes: 15, afterMinutes: 0 })).toEqual(w(0, 30));
|
||||
expect(expandWithBuffer(w(1420, 1440), { beforeMinutes: 0, afterMinutes: 30 })).toEqual(w(1420, 1440));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { AppointmentStatus } from "@/generated/prisma/enums";
|
||||
import { endOfBusinessDay, startOfBusinessDay } from "@/lib/time/business-time";
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
import { fail, ok } from "./types";
|
||||
import type { ValidationResult } from "./types";
|
||||
|
||||
/**
|
||||
* Day-of-operations concerns: retrieving appointments and moving them
|
||||
* through their status lifecycle (check-in, start, complete, no-show).
|
||||
* Deliberately has nothing to do with booking/rescheduling/availability —
|
||||
* see booking.service.ts for that. An appointment already exists by the
|
||||
* time anything in this file touches it.
|
||||
*/
|
||||
|
||||
const TRANSITIONS: Record<AppointmentStatus, AppointmentStatus[]> = {
|
||||
PENDING: [AppointmentStatus.CONFIRMED, AppointmentStatus.CANCELLED],
|
||||
CONFIRMED: [AppointmentStatus.CHECKED_IN, AppointmentStatus.CANCELLED, AppointmentStatus.NO_SHOW],
|
||||
CHECKED_IN: [AppointmentStatus.IN_PROGRESS, AppointmentStatus.CANCELLED],
|
||||
IN_PROGRESS: [AppointmentStatus.COMPLETED],
|
||||
COMPLETED: [],
|
||||
CANCELLED: [],
|
||||
NO_SHOW: [],
|
||||
};
|
||||
|
||||
export function canTransition(from: AppointmentStatus, to: AppointmentStatus): boolean {
|
||||
return TRANSITIONS[from].includes(to);
|
||||
}
|
||||
|
||||
export function validateTransition(from: AppointmentStatus, to: AppointmentStatus): ValidationResult {
|
||||
if (!canTransition(from, to)) {
|
||||
return fail({
|
||||
code: "INVALID_STATUS_TRANSITION",
|
||||
message: `An appointment can't move from ${from} to ${to}.`,
|
||||
});
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
|
||||
const APPOINTMENT_INCLUDE = {
|
||||
services: { include: { service: true } },
|
||||
staff: { include: { user: true } },
|
||||
customer: true,
|
||||
} as const;
|
||||
|
||||
export async function getAppointmentById(prisma: PrismaClient, id: string) {
|
||||
return prisma.appointment.findUnique({ where: { id }, include: APPOINTMENT_INCLUDE });
|
||||
}
|
||||
|
||||
export interface ListAppointmentsParams {
|
||||
businessId?: string;
|
||||
staffId?: string;
|
||||
customerId?: string;
|
||||
statuses?: AppointmentStatus[];
|
||||
fromDate?: CalendarDate;
|
||||
toDate?: CalendarDate;
|
||||
businessTimezone?: string; // required if fromDate/toDate given
|
||||
}
|
||||
|
||||
export async function listAppointments(prisma: PrismaClient, params: ListAppointmentsParams) {
|
||||
const dateFilter =
|
||||
params.fromDate && params.toDate && params.businessTimezone
|
||||
? {
|
||||
startAt: {
|
||||
gte: startOfBusinessDay(params.fromDate, params.businessTimezone),
|
||||
lt: endOfBusinessDay(params.toDate, params.businessTimezone),
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
return prisma.appointment.findMany({
|
||||
where: {
|
||||
...(params.businessId ? { businessId: params.businessId } : {}),
|
||||
...(params.staffId ? { staffId: params.staffId } : {}),
|
||||
...(params.customerId ? { customerId: params.customerId } : {}),
|
||||
...(params.statuses ? { status: { in: params.statuses } } : {}),
|
||||
...dateFilter,
|
||||
},
|
||||
include: APPOINTMENT_INCLUDE,
|
||||
orderBy: { startAt: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
export type TransitionResult =
|
||||
| { success: true; appointment: NonNullable<Awaited<ReturnType<typeof getAppointmentById>>> }
|
||||
| { success: false; validation: ValidationResult };
|
||||
|
||||
async function transitionStatus(prisma: PrismaClient, appointmentId: string, to: AppointmentStatus): Promise<TransitionResult> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const appointment = await tx.appointment.findUnique({ where: { id: appointmentId } });
|
||||
if (!appointment) {
|
||||
return { success: false, validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }) };
|
||||
}
|
||||
|
||||
const check = validateTransition(appointment.status, to);
|
||||
if (!check.valid) return { success: false, validation: check };
|
||||
|
||||
const updated = await tx.appointment.update({
|
||||
where: { id: appointmentId },
|
||||
data: { status: to },
|
||||
include: APPOINTMENT_INCLUDE,
|
||||
});
|
||||
return { success: true, appointment: updated };
|
||||
});
|
||||
}
|
||||
|
||||
export function checkIn(prisma: PrismaClient, appointmentId: string) {
|
||||
return transitionStatus(prisma, appointmentId, AppointmentStatus.CHECKED_IN);
|
||||
}
|
||||
|
||||
export function start(prisma: PrismaClient, appointmentId: string) {
|
||||
return transitionStatus(prisma, appointmentId, AppointmentStatus.IN_PROGRESS);
|
||||
}
|
||||
|
||||
export function complete(prisma: PrismaClient, appointmentId: string) {
|
||||
return transitionStatus(prisma, appointmentId, AppointmentStatus.COMPLETED);
|
||||
}
|
||||
|
||||
export function markNoShow(prisma: PrismaClient, appointmentId: string) {
|
||||
return transitionStatus(prisma, appointmentId, AppointmentStatus.NO_SHOW);
|
||||
}
|
||||
|
||||
export function confirm(prisma: PrismaClient, appointmentId: string) {
|
||||
return transitionStatus(prisma, appointmentId, AppointmentStatus.CONFIRMED);
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import {
|
||||
calendarDateOf,
|
||||
calendarDateToUtcMidnight,
|
||||
eachCalendarDate,
|
||||
endOfBusinessDay,
|
||||
minutesFromMidnight,
|
||||
startOfBusinessDay,
|
||||
todayIn,
|
||||
wallClockToInstant,
|
||||
weekdayOf,
|
||||
} from "@/lib/time/business-time";
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
import {
|
||||
checkBusinessDailyLimit,
|
||||
checkMaxAdvance,
|
||||
checkMinNotice,
|
||||
checkStaffDailyLimit,
|
||||
combinedServiceDuration,
|
||||
computeEffectiveBuffer,
|
||||
} from "./business-rules.service";
|
||||
import type { BookingRuleConfig } from "./business-rules.service";
|
||||
import { computeFreeWindows, generateSlotStarts } from "./slot-calculator";
|
||||
import {
|
||||
businessOpenByWeekdayFrom,
|
||||
getStaffFreeWindowsForRange,
|
||||
} from "./staff-availability.service";
|
||||
import { fail, mergeValidation, ok } from "./types";
|
||||
import type { ServiceRequirement, TimeWindow, ValidationResult } from "./types";
|
||||
|
||||
/**
|
||||
* The customer-facing query layer: "what services/barbers/dates/times can
|
||||
* actually be booked right now". Built entirely on StaffAvailabilityService
|
||||
* (per-staff free windows) and BusinessRulesService (booking-window/limit
|
||||
* checks) — this module owns combining them across staff and services, not
|
||||
* the underlying per-staff or per-rule logic.
|
||||
*/
|
||||
|
||||
export interface AvailableService {
|
||||
id: string;
|
||||
categoryId: string;
|
||||
name: string;
|
||||
durationMinutes: number;
|
||||
price: string; // Decimal serialized to string — currency formatting is a presentation concern, not this layer's
|
||||
bufferBeforeMinutes: number;
|
||||
bufferAfterMinutes: number;
|
||||
}
|
||||
|
||||
export async function getAvailableServices(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; categoryId?: string; staffId?: string },
|
||||
): Promise<AvailableService[]> {
|
||||
const services = await prisma.service.findMany({
|
||||
where: {
|
||||
businessId: params.businessId,
|
||||
isActive: true,
|
||||
allowOnlineBooking: true,
|
||||
...(params.categoryId ? { categoryId: params.categoryId } : {}),
|
||||
...(params.staffId ? { staff: { some: { staffId: params.staffId } } } : {}),
|
||||
},
|
||||
orderBy: [{ displayOrder: "asc" }, { name: "asc" }],
|
||||
});
|
||||
|
||||
return services.map((s) => ({
|
||||
id: s.id,
|
||||
categoryId: s.categoryId,
|
||||
name: s.name,
|
||||
durationMinutes: s.durationMinutes,
|
||||
price: s.price.toString(),
|
||||
bufferBeforeMinutes: s.bufferBeforeMinutes,
|
||||
bufferAfterMinutes: s.bufferAfterMinutes,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface AvailableStaffMember {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
/** Staff who can perform every requested service (a StaffService row must exist for each), are active, and are bookable online. */
|
||||
export async function getAvailableStaff(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; serviceIds: string[] },
|
||||
): Promise<AvailableStaffMember[]> {
|
||||
if (params.serviceIds.length === 0) return [];
|
||||
|
||||
const staff = await prisma.staffProfile.findMany({
|
||||
where: {
|
||||
businessId: params.businessId,
|
||||
status: "ACTIVE",
|
||||
isBookableOnline: true,
|
||||
AND: params.serviceIds.map((serviceId) => ({ services: { some: { serviceId } } })),
|
||||
},
|
||||
include: { user: { select: { name: true, image: true } } },
|
||||
});
|
||||
|
||||
return staff.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.user.name ?? "Staff member",
|
||||
avatarUrl: s.user.image,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface ResolvedBookingContext {
|
||||
business: { id: string; timezone: string };
|
||||
bookingRule: BookingRuleConfig;
|
||||
services: ServiceRequirement[];
|
||||
totalDurationMinutes: number;
|
||||
effectiveBuffer: { beforeMinutes: number; afterMinutes: number };
|
||||
eligibleStaffIds: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_BOOKING_RULE: BookingRuleConfig = {
|
||||
minNoticeMinutes: 120,
|
||||
maxAdvanceDays: 60,
|
||||
cancellationWindowHours: 24,
|
||||
rescheduleWindowHours: 24,
|
||||
slotIntervalMinutes: 30,
|
||||
bufferBeforeMinutes: 0,
|
||||
bufferAfterMinutes: 5,
|
||||
maxAppointmentsPerDay: null,
|
||||
maxAppointmentsPerStaffPerDay: null,
|
||||
customerCanChooseStaff: true,
|
||||
allowGuestBooking: true,
|
||||
requireApproval: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared setup for every availability query and for booking validation:
|
||||
* resolve the business, its booking rules, the requested services, and
|
||||
* which staff can actually perform all of them. Throws (via a
|
||||
* ValidationResult the caller should check) rather than silently
|
||||
* returning an empty result, so callers get a meaningful reason.
|
||||
*/
|
||||
export async function resolveBookingContext(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; serviceIds: string[]; staffId?: string },
|
||||
): Promise<{ context: ResolvedBookingContext | null; validation: ValidationResult }> {
|
||||
if (params.serviceIds.length === 0) {
|
||||
return { context: null, validation: fail({ code: "NO_SERVICES_SELECTED", message: "Select at least one service." }) };
|
||||
}
|
||||
|
||||
const business = await prisma.business.findUnique({
|
||||
where: { id: params.businessId },
|
||||
select: { id: true, timezone: true, bookingRule: true },
|
||||
});
|
||||
if (!business) {
|
||||
return { context: null, validation: fail({ code: "BUSINESS_NOT_FOUND", message: "Business not found." }) };
|
||||
}
|
||||
|
||||
const services = await prisma.service.findMany({
|
||||
where: { id: { in: params.serviceIds }, businessId: params.businessId, isActive: true },
|
||||
});
|
||||
if (services.length !== params.serviceIds.length) {
|
||||
return {
|
||||
context: null,
|
||||
validation: fail({
|
||||
code: "SERVICES_SPAN_MULTIPLE_BUSINESSES",
|
||||
message: "One or more selected services are not available at this business.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const serviceRequirements: ServiceRequirement[] = services.map((s) => ({
|
||||
serviceId: s.id,
|
||||
durationMinutes: s.durationMinutes,
|
||||
bufferBeforeMinutes: s.bufferBeforeMinutes,
|
||||
bufferAfterMinutes: s.bufferAfterMinutes,
|
||||
price: s.price.toString(),
|
||||
}));
|
||||
|
||||
let eligibleStaffIds: string[];
|
||||
if (params.staffId) {
|
||||
const staffServiceCount = await prisma.staffService.count({
|
||||
where: { staffId: params.staffId, serviceId: { in: params.serviceIds } },
|
||||
});
|
||||
const canPerformAll = staffServiceCount === params.serviceIds.length;
|
||||
if (!canPerformAll) {
|
||||
return {
|
||||
context: null,
|
||||
validation: fail({
|
||||
code: "STAFF_CANNOT_PERFORM_SERVICE",
|
||||
message: "This staff member doesn't offer one or more of the selected services.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
eligibleStaffIds = [params.staffId];
|
||||
} else {
|
||||
const eligible = await getAvailableStaff(prisma, { businessId: params.businessId, serviceIds: params.serviceIds });
|
||||
eligibleStaffIds = eligible.map((s) => s.id);
|
||||
}
|
||||
|
||||
const bookingRule: BookingRuleConfig = business.bookingRule ?? DEFAULT_BOOKING_RULE;
|
||||
|
||||
return {
|
||||
context: {
|
||||
business: { id: business.id, timezone: business.timezone },
|
||||
bookingRule,
|
||||
services: serviceRequirements,
|
||||
totalDurationMinutes: combinedServiceDuration(serviceRequirements),
|
||||
effectiveBuffer: computeEffectiveBuffer(bookingRule, serviceRequirements),
|
||||
eligibleStaffIds,
|
||||
},
|
||||
validation: ok(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface AvailableDatesParams {
|
||||
businessId: string;
|
||||
serviceIds: string[];
|
||||
staffId?: string;
|
||||
fromDate: CalendarDate;
|
||||
toDate: CalendarDate;
|
||||
}
|
||||
|
||||
export async function getAvailableDates(
|
||||
prisma: PrismaClient,
|
||||
params: AvailableDatesParams,
|
||||
): Promise<{ dates: CalendarDate[]; validation: ValidationResult }> {
|
||||
const { context, validation } = await resolveBookingContext(prisma, params);
|
||||
if (!context || !validation.valid) return { dates: [], validation };
|
||||
if (context.eligibleStaffIds.length === 0) {
|
||||
return { dates: [], validation: fail({ code: "STAFF_CANNOT_PERFORM_SERVICE", message: "No staff can perform the selected services." }) };
|
||||
}
|
||||
|
||||
const businessHours = await prisma.businessHours.findMany({ where: { businessId: context.business.id } });
|
||||
const businessOpenByWeekday = businessOpenByWeekdayFrom(businessHours);
|
||||
|
||||
const today = todayIn(context.business.timezone);
|
||||
const now = new Date();
|
||||
|
||||
const perStaffWindows = await Promise.all(
|
||||
context.eligibleStaffIds.map((staffId) =>
|
||||
getStaffFreeWindowsForRange(prisma, {
|
||||
staffId,
|
||||
fromDate: params.fromDate,
|
||||
toDate: params.toDate,
|
||||
businessTimezone: context.business.timezone,
|
||||
businessOpenByWeekday,
|
||||
effectiveBuffer: context.effectiveBuffer,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const businessCounts = await countAppointmentsPerDate(prisma, {
|
||||
businessId: context.business.id,
|
||||
fromDate: params.fromDate,
|
||||
toDate: params.toDate,
|
||||
businessTimezone: context.business.timezone,
|
||||
});
|
||||
const staffCounts = await Promise.all(
|
||||
context.eligibleStaffIds.map((staffId) =>
|
||||
countAppointmentsPerDate(prisma, {
|
||||
businessId: context.business.id,
|
||||
staffId,
|
||||
fromDate: params.fromDate,
|
||||
toDate: params.toDate,
|
||||
businessTimezone: context.business.timezone,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const availableDates: CalendarDate[] = [];
|
||||
|
||||
for (const date of eachCalendarDate(params.fromDate, params.toDate)) {
|
||||
if (!checkMaxAdvance(date, today, context.bookingRule.maxAdvanceDays).valid) continue;
|
||||
|
||||
const businessCount = businessCounts.get(date) ?? 0;
|
||||
if (!checkBusinessDailyLimit(businessCount, context.bookingRule.maxAppointmentsPerDay).valid) continue;
|
||||
|
||||
const hasAnyFittingSlot = context.eligibleStaffIds.some((staffId, i) => {
|
||||
const staffCount = staffCounts[i]?.get(date) ?? 0;
|
||||
if (!checkStaffDailyLimit(staffCount, context.bookingRule.maxAppointmentsPerStaffPerDay).valid) return false;
|
||||
|
||||
const freeWindows = perStaffWindows[i]?.get(date) ?? [];
|
||||
const starts = generateSlotStarts(freeWindows, {
|
||||
slotIntervalMinutes: context.bookingRule.slotIntervalMinutes,
|
||||
durationMinutes: context.totalDurationMinutes,
|
||||
});
|
||||
return starts.some((startMinutes) => {
|
||||
const instant = wallClockToInstant(date, startMinutes, context.business.timezone);
|
||||
return checkMinNotice(instant, now, context.bookingRule.minNoticeMinutes).valid;
|
||||
});
|
||||
});
|
||||
|
||||
if (hasAnyFittingSlot) availableDates.push(date);
|
||||
}
|
||||
|
||||
return { dates: availableDates, validation: ok() };
|
||||
}
|
||||
|
||||
export interface TimeSlotOption {
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
startAt: string; // ISO instant
|
||||
endAt: string;
|
||||
staffOptions: string[];
|
||||
}
|
||||
|
||||
export interface AvailableTimeSlotsParams {
|
||||
businessId: string;
|
||||
serviceIds: string[];
|
||||
staffId?: string;
|
||||
date: CalendarDate;
|
||||
}
|
||||
|
||||
export async function getAvailableTimeSlots(
|
||||
prisma: PrismaClient,
|
||||
params: AvailableTimeSlotsParams,
|
||||
): Promise<{ slots: TimeSlotOption[]; validation: ValidationResult }> {
|
||||
const { context, validation } = await resolveBookingContext(prisma, params);
|
||||
if (!context || !validation.valid) return { slots: [], validation };
|
||||
if (context.eligibleStaffIds.length === 0) {
|
||||
return { slots: [], validation: fail({ code: "STAFF_CANNOT_PERFORM_SERVICE", message: "No staff can perform the selected services." }) };
|
||||
}
|
||||
|
||||
const today = todayIn(context.business.timezone);
|
||||
const now = new Date();
|
||||
|
||||
const advanceCheck = checkMaxAdvance(params.date, today, context.bookingRule.maxAdvanceDays);
|
||||
if (!advanceCheck.valid) return { slots: [], validation: advanceCheck };
|
||||
|
||||
const businessHours = await prisma.businessHours.findMany({ where: { businessId: context.business.id } });
|
||||
const businessOpenByWeekday = businessOpenByWeekdayFrom(businessHours);
|
||||
|
||||
const businessCount = (
|
||||
await countAppointmentsPerDate(prisma, {
|
||||
businessId: context.business.id,
|
||||
fromDate: params.date,
|
||||
toDate: params.date,
|
||||
businessTimezone: context.business.timezone,
|
||||
})
|
||||
).get(params.date) ?? 0;
|
||||
if (!checkBusinessDailyLimit(businessCount, context.bookingRule.maxAppointmentsPerDay).valid) {
|
||||
return { slots: [], validation: ok() }; // no slots, but this isn't a validation error for the query itself
|
||||
}
|
||||
|
||||
const byStartMinute = new Map<number, TimeSlotOption>();
|
||||
|
||||
for (const staffId of context.eligibleStaffIds) {
|
||||
const staffCount =
|
||||
(
|
||||
await countAppointmentsPerDate(prisma, {
|
||||
businessId: context.business.id,
|
||||
staffId,
|
||||
fromDate: params.date,
|
||||
toDate: params.date,
|
||||
businessTimezone: context.business.timezone,
|
||||
})
|
||||
).get(params.date) ?? 0;
|
||||
if (!checkStaffDailyLimit(staffCount, context.bookingRule.maxAppointmentsPerStaffPerDay).valid) continue;
|
||||
|
||||
const windowsByDate = await getStaffFreeWindowsForRange(prisma, {
|
||||
staffId,
|
||||
fromDate: params.date,
|
||||
toDate: params.date,
|
||||
businessTimezone: context.business.timezone,
|
||||
businessOpenByWeekday,
|
||||
effectiveBuffer: context.effectiveBuffer,
|
||||
});
|
||||
const freeWindows = windowsByDate.get(params.date) ?? [];
|
||||
const starts = generateSlotStarts(freeWindows, {
|
||||
slotIntervalMinutes: context.bookingRule.slotIntervalMinutes,
|
||||
durationMinutes: context.totalDurationMinutes,
|
||||
});
|
||||
|
||||
for (const startMinutes of starts) {
|
||||
const startAt = wallClockToInstant(params.date, startMinutes, context.business.timezone);
|
||||
if (!checkMinNotice(startAt, now, context.bookingRule.minNoticeMinutes).valid) continue;
|
||||
|
||||
const endMinutes = startMinutes + context.totalDurationMinutes;
|
||||
const existing = byStartMinute.get(startMinutes);
|
||||
if (existing) {
|
||||
existing.staffOptions.push(staffId);
|
||||
} else {
|
||||
byStartMinute.set(startMinutes, {
|
||||
startMinutes,
|
||||
endMinutes,
|
||||
startAt: startAt.toISOString(),
|
||||
endAt: wallClockToInstant(params.date, endMinutes, context.business.timezone).toISOString(),
|
||||
staffOptions: [staffId],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const slots = [...byStartMinute.values()].sort((a, b) => a.startMinutes - b.startMinutes);
|
||||
return { slots, validation: ok() };
|
||||
}
|
||||
|
||||
export interface ValidateBookingSlotParams {
|
||||
businessId: string;
|
||||
serviceIds: string[];
|
||||
/** Must already be a specific staff member — BookingService resolves "any available barber" to one before calling this. */
|
||||
staffId: string;
|
||||
startAt: Date;
|
||||
/** When re-validating a reschedule, exclude the appointment being moved from every conflict/limit check. */
|
||||
excludeAppointmentId?: string;
|
||||
}
|
||||
|
||||
export interface ValidatedBookingSlot {
|
||||
validation: ValidationResult;
|
||||
context: ResolvedBookingContext | null;
|
||||
endAt: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnoses a single candidate (business, services, staff, start time) in
|
||||
* full, producing the specific, distinct error the brief calls for
|
||||
* ("business is open", "barber is working", "not on a break", "not on
|
||||
* leave", "requested time is available", "services fit", "booking rules
|
||||
* satisfied") rather than one generic "unavailable". This is a
|
||||
* synchronous-feeling *diagnosis* for good error messages; the
|
||||
* authoritative, race-safe check that actually guards against double
|
||||
* booking still happens inside SchedulingService's transaction at commit
|
||||
* time — this function can be (rarely) stale by the time a booking
|
||||
* actually commits, same as any other read-then-write.
|
||||
*/
|
||||
export async function validateBookingSlot(
|
||||
prisma: PrismaClient,
|
||||
params: ValidateBookingSlotParams,
|
||||
): Promise<ValidatedBookingSlot> {
|
||||
const { context, validation: contextValidation } = await resolveBookingContext(prisma, {
|
||||
businessId: params.businessId,
|
||||
serviceIds: params.serviceIds,
|
||||
staffId: params.staffId,
|
||||
});
|
||||
if (!context || !contextValidation.valid) return { context: null, validation: contextValidation, endAt: null };
|
||||
|
||||
const { timezone } = context.business;
|
||||
const date = calendarDateOf(params.startAt, timezone);
|
||||
const startMinutes = minutesFromMidnight(params.startAt, timezone);
|
||||
const endMinutes = startMinutes + context.totalDurationMinutes;
|
||||
const endAt = wallClockToInstant(date, endMinutes, timezone);
|
||||
const now = new Date();
|
||||
const today = todayIn(timezone);
|
||||
|
||||
const ruleChecks = mergeValidation(
|
||||
checkMinNotice(params.startAt, now, context.bookingRule.minNoticeMinutes),
|
||||
checkMaxAdvance(date, today, context.bookingRule.maxAdvanceDays),
|
||||
);
|
||||
|
||||
const businessHours = await prisma.businessHours.findMany({ where: { businessId: context.business.id } });
|
||||
const businessOpen = businessOpenByWeekdayFrom(businessHours).get(weekdayOf(date));
|
||||
|
||||
if (!businessOpen || startMinutes < businessOpen.startMinutes || endMinutes > businessOpen.endMinutes) {
|
||||
return {
|
||||
context,
|
||||
endAt,
|
||||
validation: mergeValidation(ruleChecks, fail({ code: "BUSINESS_CLOSED", message: "The business is closed at this time." })),
|
||||
};
|
||||
}
|
||||
|
||||
const [workingHours, breakDays, activeTimeOff, businessCount, staffCount] = await Promise.all([
|
||||
prisma.workingHours.findUnique({ where: { staffId_dayOfWeek: { staffId: params.staffId, dayOfWeek: weekdayOf(date) } } }),
|
||||
prisma.breakDay.findMany({ where: { dayOfWeek: weekdayOf(date), break: { staffId: params.staffId } }, include: { break: true } }),
|
||||
prisma.timeOff.findFirst({
|
||||
where: {
|
||||
staffId: params.staffId,
|
||||
status: "APPROVED",
|
||||
startDate: { lte: calendarDateToUtcMidnight(date) },
|
||||
endDate: { gte: calendarDateToUtcMidnight(date) },
|
||||
},
|
||||
}),
|
||||
countAppointmentsPerDate(prisma, {
|
||||
businessId: context.business.id,
|
||||
fromDate: date,
|
||||
toDate: date,
|
||||
businessTimezone: timezone,
|
||||
excludeAppointmentId: params.excludeAppointmentId,
|
||||
}),
|
||||
countAppointmentsPerDate(prisma, {
|
||||
businessId: context.business.id,
|
||||
staffId: params.staffId,
|
||||
fromDate: date,
|
||||
toDate: date,
|
||||
businessTimezone: timezone,
|
||||
excludeAppointmentId: params.excludeAppointmentId,
|
||||
}),
|
||||
]);
|
||||
|
||||
const staffWorking: TimeWindow | null =
|
||||
workingHours && !workingHours.isOff && workingHours.startMinutes !== null && workingHours.endMinutes !== null
|
||||
? { startMinutes: workingHours.startMinutes, endMinutes: workingHours.endMinutes }
|
||||
: null;
|
||||
|
||||
if (!staffWorking) {
|
||||
return { context, endAt, validation: mergeValidation(ruleChecks, fail({ code: "STAFF_NOT_WORKING", message: "This staff member isn't scheduled to work at this time." })) };
|
||||
}
|
||||
|
||||
if (activeTimeOff) {
|
||||
return { context, endAt, validation: mergeValidation(ruleChecks, fail({ code: "STAFF_ON_LEAVE", message: "This staff member is on approved leave that day." })) };
|
||||
}
|
||||
|
||||
const breaks: TimeWindow[] = breakDays.map((bd) => ({ startMinutes: bd.break.startMinutes, endMinutes: bd.break.endMinutes }));
|
||||
const onBreak = breaks.some((b) => startMinutes < b.endMinutes && endMinutes > b.startMinutes);
|
||||
if (onBreak) {
|
||||
return { context, endAt, validation: mergeValidation(ruleChecks, fail({ code: "STAFF_ON_BREAK", message: "This staff member is on a break during part of this time." })) };
|
||||
}
|
||||
|
||||
// Theoretical window: hours ∩ working hours, minus breaks, WITHOUT existing appointments — distinguishes
|
||||
// "the services just don't fit in the remaining open time" from "someone else booked over this".
|
||||
const theoreticalWindows = computeFreeWindows({ businessOpen, staffWorking, breaks, onLeave: false, busy: [] });
|
||||
const fitsTheoretically = theoreticalWindows.some((w) => startMinutes >= w.startMinutes && endMinutes <= w.endMinutes);
|
||||
if (!fitsTheoretically) {
|
||||
return {
|
||||
context,
|
||||
endAt,
|
||||
validation: mergeValidation(ruleChecks, fail({ code: "INSUFFICIENT_DURATION", message: "The selected services don't fit before this staff member's hours end." })),
|
||||
};
|
||||
}
|
||||
|
||||
const businessCapCheck = checkBusinessDailyLimit(businessCount.get(date) ?? 0, context.bookingRule.maxAppointmentsPerDay);
|
||||
const staffCapCheck = checkStaffDailyLimit(staffCount.get(date) ?? 0, context.bookingRule.maxAppointmentsPerStaffPerDay);
|
||||
|
||||
const actualWindows = await getStaffFreeWindowsForRange(prisma, {
|
||||
staffId: params.staffId,
|
||||
fromDate: date,
|
||||
toDate: date,
|
||||
businessTimezone: timezone,
|
||||
businessOpenByWeekday: { get: (w: Weekday) => (w === weekdayOf(date) ? businessOpen : null) },
|
||||
effectiveBuffer: context.effectiveBuffer,
|
||||
excludeAppointmentId: params.excludeAppointmentId,
|
||||
});
|
||||
const fitsActually = (actualWindows.get(date) ?? []).some((w) => startMinutes >= w.startMinutes && endMinutes <= w.endMinutes);
|
||||
const slotCheck = fitsActually ? ok() : fail({ code: "SLOT_UNAVAILABLE", message: "This time was just booked — please choose another." });
|
||||
|
||||
return {
|
||||
context,
|
||||
endAt,
|
||||
validation: mergeValidation(ruleChecks, businessCapCheck, staffCapCheck, slotCheck),
|
||||
};
|
||||
}
|
||||
|
||||
async function countAppointmentsPerDate(
|
||||
prisma: PrismaClient,
|
||||
params: {
|
||||
businessId: string;
|
||||
staffId?: string;
|
||||
fromDate: CalendarDate;
|
||||
toDate: CalendarDate;
|
||||
businessTimezone: string;
|
||||
excludeAppointmentId?: string;
|
||||
},
|
||||
): Promise<Map<CalendarDate, number>> {
|
||||
const rangeStart = startOfBusinessDay(params.fromDate, params.businessTimezone);
|
||||
const rangeEnd = endOfBusinessDay(params.toDate, params.businessTimezone);
|
||||
|
||||
const appointments = await prisma.appointment.findMany({
|
||||
where: {
|
||||
businessId: params.businessId,
|
||||
...(params.staffId ? { staffId: params.staffId } : {}),
|
||||
...(params.excludeAppointmentId ? { id: { not: params.excludeAppointmentId } } : {}),
|
||||
status: { notIn: ["CANCELLED", "NO_SHOW"] },
|
||||
startAt: { gte: rangeStart, lt: rangeEnd },
|
||||
},
|
||||
select: { startAt: true },
|
||||
});
|
||||
|
||||
const counts = new Map<CalendarDate, number>();
|
||||
for (const a of appointments) {
|
||||
const date = calendarDateOf(a.startAt, params.businessTimezone);
|
||||
counts.set(date, (counts.get(date) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { AppointmentStatus } from "@/generated/prisma/enums";
|
||||
import { calendarDateOf, minutesFromMidnight } from "@/lib/time/business-time";
|
||||
import { getAppointmentById } from "./appointment.service";
|
||||
import { getAvailableTimeSlots, resolveBookingContext, validateBookingSlot } from "./availability.service";
|
||||
import { isLateCancellation, isLateReschedule } from "./business-rules.service";
|
||||
import { DoubleBookingError, createAppointment, rescheduleAppointment } from "./scheduling.service";
|
||||
import { countStaffAppointmentsOnDate } from "./staff-availability.service";
|
||||
import { fail, ok } from "./types";
|
||||
import type { ValidationResult } from "./types";
|
||||
|
||||
/**
|
||||
* The full booking workflow: create, reschedule, cancel. Orchestrates
|
||||
* AvailabilityService (validate the request) and SchedulingService
|
||||
* (commit it transactionally) — this file makes no availability or
|
||||
* conflict decisions of its own, it coordinates the services that do.
|
||||
*/
|
||||
|
||||
export interface CreateBookingInput {
|
||||
businessId: string;
|
||||
customerId: string;
|
||||
serviceIds: string[];
|
||||
/** Omit for "any available barber". */
|
||||
staffId?: string;
|
||||
startAt: Date;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export type Appointment = NonNullable<Awaited<ReturnType<typeof getAppointmentById>>>;
|
||||
|
||||
export type BookingResult = { success: true; appointment: Appointment } | { success: false; validation: ValidationResult };
|
||||
|
||||
/** Among staff free at this slot, prefer whoever has the fewest appointments that day — spreads "any barber" bookings out instead of always picking the same one. */
|
||||
async function pickLeastBusyStaff(
|
||||
prisma: PrismaClient,
|
||||
candidateStaffIds: string[],
|
||||
date: string,
|
||||
businessTimezone: string,
|
||||
): Promise<string> {
|
||||
if (candidateStaffIds.length === 1) return candidateStaffIds[0]!;
|
||||
|
||||
const counts = await Promise.all(
|
||||
candidateStaffIds.map((staffId) => countStaffAppointmentsOnDate(prisma, { staffId, date, businessTimezone })),
|
||||
);
|
||||
|
||||
let best = candidateStaffIds[0]!;
|
||||
let bestCount = counts[0]!;
|
||||
for (let i = 1; i < candidateStaffIds.length; i++) {
|
||||
const count = counts[i]!;
|
||||
if (count < bestCount) {
|
||||
best = candidateStaffIds[i]!;
|
||||
bestCount = count;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export async function createBooking(prisma: PrismaClient, input: CreateBookingInput): Promise<BookingResult> {
|
||||
const { context: baseContext, validation: baseValidation } = await resolveBookingContext(prisma, {
|
||||
businessId: input.businessId,
|
||||
serviceIds: input.serviceIds,
|
||||
staffId: input.staffId,
|
||||
});
|
||||
if (!baseContext || !baseValidation.valid) return { success: false, validation: baseValidation };
|
||||
|
||||
let staffId = input.staffId;
|
||||
if (!staffId) {
|
||||
if (baseContext.eligibleStaffIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({ code: "STAFF_CANNOT_PERFORM_SERVICE", message: "No staff can perform the selected services." }),
|
||||
};
|
||||
}
|
||||
|
||||
const date = calendarDateOf(input.startAt, baseContext.business.timezone);
|
||||
const startMinutes = minutesFromMidnight(input.startAt, baseContext.business.timezone);
|
||||
const { slots } = await getAvailableTimeSlots(prisma, {
|
||||
businessId: input.businessId,
|
||||
serviceIds: input.serviceIds,
|
||||
date,
|
||||
});
|
||||
const matching = slots.find((s) => s.startMinutes === startMinutes);
|
||||
if (!matching || matching.staffOptions.length === 0) {
|
||||
return { success: false, validation: fail({ code: "SLOT_UNAVAILABLE", message: "This time is no longer available." }) };
|
||||
}
|
||||
staffId = await pickLeastBusyStaff(prisma, matching.staffOptions, date, baseContext.business.timezone);
|
||||
}
|
||||
|
||||
const { validation, context, endAt } = await validateBookingSlot(prisma, {
|
||||
businessId: input.businessId,
|
||||
serviceIds: input.serviceIds,
|
||||
staffId,
|
||||
startAt: input.startAt,
|
||||
});
|
||||
if (!validation.valid || !context || !endAt) return { success: false, validation };
|
||||
|
||||
const status = context.bookingRule.requireApproval ? AppointmentStatus.PENDING : AppointmentStatus.CONFIRMED;
|
||||
|
||||
try {
|
||||
const created = await createAppointment(prisma, {
|
||||
businessId: input.businessId,
|
||||
customerId: input.customerId,
|
||||
staffId,
|
||||
startAt: input.startAt,
|
||||
endAt,
|
||||
status,
|
||||
notes: input.notes,
|
||||
effectiveBuffer: context.effectiveBuffer,
|
||||
services: context.services.map((s) => ({
|
||||
serviceId: s.serviceId,
|
||||
priceAtBooking: s.price,
|
||||
durationAtBookingMinutes: s.durationMinutes,
|
||||
})),
|
||||
});
|
||||
// Re-fetch through the shared include shape so callers get the same
|
||||
// appointment shape regardless of which workflow produced it.
|
||||
const appointment = await getAppointmentById(prisma, created.id);
|
||||
return { success: true, appointment: appointment! };
|
||||
} catch (error) {
|
||||
if (error instanceof DoubleBookingError) {
|
||||
return { success: false, validation: fail({ code: "SLOT_UNAVAILABLE", message: error.message }) };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RescheduleBookingInput {
|
||||
appointmentId: string;
|
||||
/** Omit to keep the current staff member, or supply a different one. */
|
||||
staffId?: string;
|
||||
startAt: Date;
|
||||
}
|
||||
|
||||
const NON_RESCHEDULABLE_STATUSES: AppointmentStatus[] = [
|
||||
AppointmentStatus.COMPLETED,
|
||||
AppointmentStatus.CANCELLED,
|
||||
AppointmentStatus.NO_SHOW,
|
||||
AppointmentStatus.IN_PROGRESS,
|
||||
];
|
||||
|
||||
/** Rescheduling performs the same validation as a new booking — it's a new slot, just for an appointment that already exists. */
|
||||
export async function rescheduleBooking(prisma: PrismaClient, input: RescheduleBookingInput): Promise<BookingResult> {
|
||||
const existing = await getAppointmentById(prisma, input.appointmentId);
|
||||
if (!existing) {
|
||||
return { success: false, validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }) };
|
||||
}
|
||||
if (NON_RESCHEDULABLE_STATUSES.includes(existing.status)) {
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({
|
||||
code: "INVALID_STATUS_TRANSITION",
|
||||
message: `A ${existing.status.toLowerCase().replace("_", " ")} appointment can't be rescheduled.`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const serviceIds = existing.services.map((s) => s.serviceId);
|
||||
const staffId = input.staffId ?? existing.staffId;
|
||||
|
||||
const { validation, context, endAt } = await validateBookingSlot(prisma, {
|
||||
businessId: existing.businessId,
|
||||
serviceIds,
|
||||
staffId,
|
||||
startAt: input.startAt,
|
||||
excludeAppointmentId: input.appointmentId,
|
||||
});
|
||||
if (!validation.valid || !context || !endAt) return { success: false, validation };
|
||||
|
||||
try {
|
||||
await rescheduleAppointment(prisma, {
|
||||
appointmentId: input.appointmentId,
|
||||
staffId,
|
||||
startAt: input.startAt,
|
||||
endAt,
|
||||
effectiveBuffer: context.effectiveBuffer,
|
||||
});
|
||||
const appointment = await getAppointmentById(prisma, input.appointmentId);
|
||||
return { success: true, appointment: appointment! };
|
||||
} catch (error) {
|
||||
if (error instanceof DoubleBookingError) {
|
||||
return { success: false, validation: fail({ code: "SLOT_UNAVAILABLE", message: error.message }) };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CancelBookingInput {
|
||||
appointmentId: string;
|
||||
cancelledByUserId: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CancelBookingResult {
|
||||
success: boolean;
|
||||
appointment?: Appointment;
|
||||
/** Cancellation is never blocked by the cancellation-window rule — this just tells the caller whether it fell outside the free window, e.g. to show a "note: this was a late cancellation" message. */
|
||||
wasLateCancellation?: boolean;
|
||||
validation: ValidationResult;
|
||||
}
|
||||
|
||||
const NON_CANCELLABLE_STATUSES: AppointmentStatus[] = [
|
||||
AppointmentStatus.COMPLETED,
|
||||
AppointmentStatus.CANCELLED,
|
||||
AppointmentStatus.NO_SHOW,
|
||||
];
|
||||
|
||||
/** Never deletes — sets status to CANCELLED and records when, why, and by whom. */
|
||||
export async function cancelBooking(prisma: PrismaClient, input: CancelBookingInput): Promise<CancelBookingResult> {
|
||||
const existing = await getAppointmentById(prisma, input.appointmentId);
|
||||
if (!existing) {
|
||||
return { success: false, validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }) };
|
||||
}
|
||||
if (NON_CANCELLABLE_STATUSES.includes(existing.status)) {
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({
|
||||
code: "INVALID_STATUS_TRANSITION",
|
||||
message: `A ${existing.status.toLowerCase().replace("_", " ")} appointment can't be cancelled.`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const business = await prisma.business.findUnique({
|
||||
where: { id: existing.businessId },
|
||||
select: { bookingRule: { select: { cancellationWindowHours: true } } },
|
||||
});
|
||||
const cancellationWindowHours = business?.bookingRule?.cancellationWindowHours ?? 24;
|
||||
const wasLateCancellation = isLateCancellation(existing.startAt, new Date(), cancellationWindowHours);
|
||||
|
||||
const appointment = await prisma.appointment.update({
|
||||
where: { id: input.appointmentId },
|
||||
data: {
|
||||
status: AppointmentStatus.CANCELLED,
|
||||
cancelledAt: new Date(),
|
||||
cancelledByUserId: input.cancelledByUserId,
|
||||
cancellationReason: input.reason,
|
||||
},
|
||||
include: { services: { include: { service: true } }, staff: { include: { user: true } }, customer: true },
|
||||
});
|
||||
|
||||
return { success: true, appointment, wasLateCancellation, validation: ok() };
|
||||
}
|
||||
|
||||
/** Exposed for symmetry with cancellation's "late" flag — a UI can warn before submitting a reschedule request. */
|
||||
export function checkRescheduleWindow(appointmentStart: Date, now: Date, rescheduleWindowHours: number): boolean {
|
||||
return isLateReschedule(appointmentStart, now, rescheduleWindowHours);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { diffMinutes } from "@/lib/time/business-time";
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
import { fail, ok } from "./types";
|
||||
import type { ServiceRequirement, ValidationResult } from "./types";
|
||||
|
||||
/**
|
||||
* Evaluates a business's BookingRule configuration against a candidate
|
||||
* booking. Pure functions — every input is a plain value the caller
|
||||
* already fetched, nothing here touches Prisma. This is deliberate:
|
||||
* booking-rule evaluation is exactly the kind of logic that needs to be
|
||||
* cheap to unit test with dozens of edge-case combinations.
|
||||
*/
|
||||
|
||||
export interface BookingRuleConfig {
|
||||
minNoticeMinutes: number;
|
||||
maxAdvanceDays: number;
|
||||
cancellationWindowHours: number;
|
||||
rescheduleWindowHours: number;
|
||||
slotIntervalMinutes: number;
|
||||
bufferBeforeMinutes: number;
|
||||
bufferAfterMinutes: number;
|
||||
maxAppointmentsPerDay: number | null;
|
||||
maxAppointmentsPerStaffPerDay: number | null;
|
||||
customerCanChooseStaff: boolean;
|
||||
allowGuestBooking: boolean;
|
||||
requireApproval: boolean;
|
||||
}
|
||||
|
||||
export function checkMinNotice(candidateStart: Date, now: Date, minNoticeMinutes: number): ValidationResult {
|
||||
const noticeMinutes = diffMinutes(candidateStart, now);
|
||||
if (noticeMinutes < minNoticeMinutes) {
|
||||
return fail({
|
||||
code: "BELOW_MIN_NOTICE",
|
||||
message: `This time is less than ${minNoticeMinutes} minutes from now; the minimum booking notice is ${minNoticeMinutes} minutes.`,
|
||||
});
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
|
||||
export function checkMaxAdvance(candidateDate: CalendarDate, today: CalendarDate, maxAdvanceDays: number): ValidationResult {
|
||||
const daysAhead = Math.floor(
|
||||
(Date.parse(`${candidateDate}T00:00:00Z`) - Date.parse(`${today}T00:00:00Z`)) / 86_400_000,
|
||||
);
|
||||
if (daysAhead > maxAdvanceDays) {
|
||||
return fail({
|
||||
code: "BEYOND_MAX_ADVANCE",
|
||||
message: `Bookings can only be made up to ${maxAdvanceDays} days in advance.`,
|
||||
});
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
|
||||
export function checkBusinessDailyLimit(existingCount: number, maxAppointmentsPerDay: number | null): ValidationResult {
|
||||
if (maxAppointmentsPerDay !== null && existingCount >= maxAppointmentsPerDay) {
|
||||
return fail({
|
||||
code: "BUSINESS_DAILY_LIMIT_REACHED",
|
||||
message: "This business has reached its maximum number of appointments for this day.",
|
||||
});
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
|
||||
export function checkStaffDailyLimit(existingCount: number, maxAppointmentsPerStaffPerDay: number | null): ValidationResult {
|
||||
if (maxAppointmentsPerStaffPerDay !== null && existingCount >= maxAppointmentsPerStaffPerDay) {
|
||||
return fail({
|
||||
code: "STAFF_DAILY_LIMIT_REACHED",
|
||||
message: "This staff member has reached their maximum number of appointments for this day.",
|
||||
});
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective buffer around an appointment = the business-wide baseline
|
||||
* (BookingRule.bufferBeforeMinutes/bufferAfterMinutes — always applied)
|
||||
* PLUS the largest per-service buffer among the requested services (a
|
||||
* service like a colour treatment that needs extra processing/cleanup
|
||||
* time on top of the shop's normal gap, not instead of it).
|
||||
*/
|
||||
export function computeEffectiveBuffer(
|
||||
bookingRule: Pick<BookingRuleConfig, "bufferBeforeMinutes" | "bufferAfterMinutes">,
|
||||
services: Pick<ServiceRequirement, "bufferBeforeMinutes" | "bufferAfterMinutes">[],
|
||||
): { beforeMinutes: number; afterMinutes: number } {
|
||||
const maxServiceBefore = services.reduce((max, s) => Math.max(max, s.bufferBeforeMinutes), 0);
|
||||
const maxServiceAfter = services.reduce((max, s) => Math.max(max, s.bufferAfterMinutes), 0);
|
||||
return {
|
||||
beforeMinutes: bookingRule.bufferBeforeMinutes + maxServiceBefore,
|
||||
afterMinutes: bookingRule.bufferAfterMinutes + maxServiceAfter,
|
||||
};
|
||||
}
|
||||
|
||||
/** True if cancelling right now falls outside the free-cancellation window (i.e. it's a "late" cancellation). Informational only — cancellation is never blocked by this, see booking.service.ts. */
|
||||
export function isLateCancellation(appointmentStart: Date, now: Date, cancellationWindowHours: number): boolean {
|
||||
return diffMinutes(appointmentStart, now) < cancellationWindowHours * 60;
|
||||
}
|
||||
|
||||
/** Same idea as isLateCancellation, for reschedules. */
|
||||
export function isLateReschedule(appointmentStart: Date, now: Date, rescheduleWindowHours: number): boolean {
|
||||
return diffMinutes(appointmentStart, now) < rescheduleWindowHours * 60;
|
||||
}
|
||||
|
||||
export function combinedServiceDuration(services: Pick<ServiceRequirement, "durationMinutes">[]): number {
|
||||
return services.reduce((sum, s) => sum + s.durationMinutes, 0);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import type { AppointmentStatus } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* The one place a booking is actually committed to the database. Every
|
||||
* other part of the engine (AvailabilityService, BookingService) works
|
||||
* with a *snapshot* of the schedule that can go stale the instant another
|
||||
* request books the same slot — this function is what makes double-
|
||||
* booking prevention real rather than advisory, by re-checking for
|
||||
* conflicts inside a Serializable transaction and letting Postgres itself
|
||||
* reject a genuine race rather than trusting an application-level check
|
||||
* alone.
|
||||
*
|
||||
* Two layers of defence, both needed:
|
||||
* 1. An explicit overlap re-check against the target staff member's
|
||||
* other appointments, inside the transaction.
|
||||
* 2. SERIALIZABLE isolation, so that even a race the explicit check
|
||||
* can't see (e.g. two transactions whose reads briefly overlap) gets
|
||||
* rejected by the database with a serialization failure, which we
|
||||
* translate into the same DoubleBookingError. Serializable isolation
|
||||
* is standard SQL, supported by both Postgres and MySQL — this stays
|
||||
* portable, unlike a Postgres-only exclusion constraint would be.
|
||||
*/
|
||||
|
||||
export class DoubleBookingError extends Error {
|
||||
constructor(message = "This time is no longer available — it was just booked.") {
|
||||
super(message);
|
||||
this.name = "DoubleBookingError";
|
||||
}
|
||||
}
|
||||
|
||||
const NON_BLOCKING_STATUSES: AppointmentStatus[] = ["CANCELLED", "NO_SHOW"];
|
||||
|
||||
export interface AppointmentServiceLine {
|
||||
serviceId: string;
|
||||
priceAtBooking: Prisma.Decimal | number | string;
|
||||
durationAtBookingMinutes: number;
|
||||
}
|
||||
|
||||
export interface CreateAppointmentParams {
|
||||
businessId: string;
|
||||
customerId: string;
|
||||
staffId: string;
|
||||
startAt: Date;
|
||||
endAt: Date;
|
||||
status: AppointmentStatus;
|
||||
notes?: string;
|
||||
effectiveBuffer: { beforeMinutes: number; afterMinutes: number };
|
||||
services: AppointmentServiceLine[];
|
||||
}
|
||||
|
||||
export interface RescheduleAppointmentParams {
|
||||
appointmentId: string;
|
||||
staffId: string;
|
||||
startAt: Date;
|
||||
endAt: Date;
|
||||
effectiveBuffer: { beforeMinutes: number; afterMinutes: number };
|
||||
}
|
||||
|
||||
async function assertNoConflict(
|
||||
tx: PrismaClient,
|
||||
params: { staffId: string; startAt: Date; endAt: Date; effectiveBuffer: { beforeMinutes: number; afterMinutes: number }; excludeAppointmentId?: string },
|
||||
): Promise<void> {
|
||||
const bufferedStart = new Date(params.startAt.getTime() - params.effectiveBuffer.beforeMinutes * 60_000);
|
||||
const bufferedEnd = new Date(params.endAt.getTime() + params.effectiveBuffer.afterMinutes * 60_000);
|
||||
|
||||
const conflict = await tx.appointment.findFirst({
|
||||
where: {
|
||||
staffId: params.staffId,
|
||||
status: { notIn: NON_BLOCKING_STATUSES },
|
||||
...(params.excludeAppointmentId ? { id: { not: params.excludeAppointmentId } } : {}),
|
||||
// Overlap test: the candidate's buffered span against existing appointments' raw spans — mirrors
|
||||
// exactly how AvailabilityService treats existing appointments as buffered "busy" time (see
|
||||
// slot-calculator.ts expandWithBuffer), so a slot the availability search offered can't be
|
||||
// rejected here for a different reason than why it wouldn't have been offered in the first place.
|
||||
startAt: { lt: bufferedEnd },
|
||||
endAt: { gt: bufferedStart },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (conflict) throw new DoubleBookingError();
|
||||
}
|
||||
|
||||
function isSerializationFailure(error: unknown): boolean {
|
||||
// P2034: "Transaction failed due to a write conflict or a deadlock. Please retry your transaction."
|
||||
// This is the error shape Prisma's classic query-engine binary produces.
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2034") return true;
|
||||
|
||||
// Prisma 7's TypeScript-native client-engine-runtime (used with driver
|
||||
// adapters, see src/lib/db/prisma.ts) instead propagates the adapter's
|
||||
// own DriverAdapterError for a Postgres serialization failure (SQLSTATE
|
||||
// 40001) rather than wrapping it as P2034 — found empirically by
|
||||
// actually running the concurrency test below, not from documentation.
|
||||
// Duck-typed rather than importing @prisma/driver-adapter-utils
|
||||
// directly, since that's a transitive dependency, not one of ours.
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.name === "DriverAdapterError" &&
|
||||
"cause" in error &&
|
||||
typeof error.cause === "object" &&
|
||||
error.cause !== null &&
|
||||
"kind" in error.cause &&
|
||||
error.cause.kind === "TransactionWriteConflict"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function runSerializable<T>(prisma: PrismaClient, fn: (tx: PrismaClient) => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await prisma.$transaction(fn as (tx: Prisma.TransactionClient) => Promise<T>, {
|
||||
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSerializationFailure(error)) throw new DoubleBookingError();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAppointment(prisma: PrismaClient, params: CreateAppointmentParams) {
|
||||
return runSerializable(prisma, async (tx) => {
|
||||
await assertNoConflict(tx, {
|
||||
staffId: params.staffId,
|
||||
startAt: params.startAt,
|
||||
endAt: params.endAt,
|
||||
effectiveBuffer: params.effectiveBuffer,
|
||||
});
|
||||
|
||||
return tx.appointment.create({
|
||||
data: {
|
||||
businessId: params.businessId,
|
||||
customerId: params.customerId,
|
||||
staffId: params.staffId,
|
||||
startAt: params.startAt,
|
||||
endAt: params.endAt,
|
||||
status: params.status,
|
||||
notes: params.notes,
|
||||
services: {
|
||||
create: params.services.map((s) => ({
|
||||
serviceId: s.serviceId,
|
||||
priceAtBooking: s.priceAtBooking,
|
||||
durationAtBookingMinutes: s.durationAtBookingMinutes,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { services: true, staff: { include: { user: true } }, customer: true },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function rescheduleAppointment(prisma: PrismaClient, params: RescheduleAppointmentParams) {
|
||||
return runSerializable(prisma, async (tx) => {
|
||||
await assertNoConflict(tx, {
|
||||
staffId: params.staffId,
|
||||
startAt: params.startAt,
|
||||
endAt: params.endAt,
|
||||
effectiveBuffer: params.effectiveBuffer,
|
||||
excludeAppointmentId: params.appointmentId,
|
||||
});
|
||||
|
||||
return tx.appointment.update({
|
||||
where: { id: params.appointmentId },
|
||||
data: { staffId: params.staffId, startAt: params.startAt, endAt: params.endAt },
|
||||
include: { services: true, staff: { include: { user: true } }, customer: true },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { TimeWindow } from "./types";
|
||||
|
||||
/**
|
||||
* Pure, DB-free scheduling math for a single day. Every function here
|
||||
* operates on plain `TimeWindow` values (minutes since local midnight,
|
||||
* 0–1440) — no Prisma, no dates, no I/O. This is deliberate: it's the
|
||||
* part of the engine with the most edge cases (overlapping breaks,
|
||||
* back-to-back appointments, zero-length windows), so it's the part that
|
||||
* benefits most from being fully unit-testable without a database. The
|
||||
* orchestration layer (staff-availability.service.ts, etc.) fetches real
|
||||
* data and converts it into these shapes; this module never touches the
|
||||
* database.
|
||||
*
|
||||
* A window never crosses midnight — a barbershop day doesn't run past
|
||||
* 24:00 in this model. `startMinutes`/`endMinutes` are clamped to
|
||||
* [0, 1440] wherever a caller-supplied value (e.g. a buffer pushing an
|
||||
* appointment's busy window before opening) could otherwise escape that
|
||||
* range.
|
||||
*/
|
||||
|
||||
const DAY_START = 0;
|
||||
const DAY_END = 24 * 60;
|
||||
|
||||
function clamp(minutes: number): number {
|
||||
return Math.min(DAY_END, Math.max(DAY_START, minutes));
|
||||
}
|
||||
|
||||
function isValidWindow(w: TimeWindow): boolean {
|
||||
return w.endMinutes > w.startMinutes;
|
||||
}
|
||||
|
||||
/** Sort and merge overlapping/touching windows into the minimal equivalent set. */
|
||||
export function mergeWindows(windows: TimeWindow[]): TimeWindow[] {
|
||||
const valid = windows.filter(isValidWindow).sort((a, b) => a.startMinutes - b.startMinutes);
|
||||
const merged: TimeWindow[] = [];
|
||||
|
||||
for (const w of valid) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && w.startMinutes <= last.endMinutes) {
|
||||
last.endMinutes = Math.max(last.endMinutes, w.endMinutes);
|
||||
} else {
|
||||
merged.push({ ...w });
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Subtract every window in `toRemove` from every window in `base`, returning the remaining free fragments. */
|
||||
export function subtractWindows(base: TimeWindow[], toRemove: TimeWindow[]): TimeWindow[] {
|
||||
const removals = mergeWindows(toRemove);
|
||||
let remaining = base.filter(isValidWindow).map((w) => ({ ...w }));
|
||||
|
||||
for (const cut of removals) {
|
||||
const next: TimeWindow[] = [];
|
||||
for (const w of remaining) {
|
||||
if (cut.endMinutes <= w.startMinutes || cut.startMinutes >= w.endMinutes) {
|
||||
// No overlap.
|
||||
next.push(w);
|
||||
continue;
|
||||
}
|
||||
// Left remainder.
|
||||
if (cut.startMinutes > w.startMinutes) {
|
||||
next.push({ startMinutes: w.startMinutes, endMinutes: cut.startMinutes });
|
||||
}
|
||||
// Right remainder.
|
||||
if (cut.endMinutes < w.endMinutes) {
|
||||
next.push({ startMinutes: cut.endMinutes, endMinutes: w.endMinutes });
|
||||
}
|
||||
}
|
||||
remaining = next;
|
||||
}
|
||||
|
||||
return remaining.filter(isValidWindow);
|
||||
}
|
||||
|
||||
/** Intersect two sets of windows (e.g. business-open ∩ staff-working). */
|
||||
export function intersectWindows(a: TimeWindow[], b: TimeWindow[]): TimeWindow[] {
|
||||
const result: TimeWindow[] = [];
|
||||
for (const wa of a) {
|
||||
for (const wb of b) {
|
||||
const start = Math.max(wa.startMinutes, wb.startMinutes);
|
||||
const end = Math.min(wa.endMinutes, wb.endMinutes);
|
||||
if (end > start) result.push({ startMinutes: start, endMinutes: end });
|
||||
}
|
||||
}
|
||||
return mergeWindows(result);
|
||||
}
|
||||
|
||||
export interface FreeWindowsInput {
|
||||
/** null = business closed all day. */
|
||||
businessOpen: TimeWindow | null;
|
||||
/** null = staff not scheduled to work at all that day. */
|
||||
staffWorking: TimeWindow | null;
|
||||
/** Recurring breaks applicable to this day of week. */
|
||||
breaks: TimeWindow[];
|
||||
/** True if an approved TimeOff record covers this calendar date — blocks the whole day. */
|
||||
onLeave: boolean;
|
||||
/** Existing appointments' busy spans, already buffer-expanded by the caller. */
|
||||
busy: TimeWindow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The core availability computation for one staff member on one day:
|
||||
* business hours ∩ staff hours, minus breaks, minus (the whole day, if on
|
||||
* leave), minus buffered busy time from existing appointments.
|
||||
*/
|
||||
export function computeFreeWindows(input: FreeWindowsInput): TimeWindow[] {
|
||||
if (input.onLeave) return [];
|
||||
if (!input.businessOpen || !input.staffWorking) return [];
|
||||
|
||||
const base = intersectWindows([input.businessOpen], [input.staffWorking]);
|
||||
const withoutBreaks = subtractWindows(base, input.breaks);
|
||||
return subtractWindows(withoutBreaks, input.busy).map((w) => ({
|
||||
startMinutes: clamp(w.startMinutes),
|
||||
endMinutes: clamp(w.endMinutes),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate appointment start times: every point on the `slotIntervalMinutes`
|
||||
* grid (aligned to midnight — 0, interval, 2×interval, …) where
|
||||
* `[start, start + durationMinutes]` fits entirely inside one free window.
|
||||
* Grid-aligned rather than window-relative so slot times look like the
|
||||
* "9:00, 9:30, 10:00" a customer expects, not odd offsets derived from
|
||||
* when a previous appointment happened to end.
|
||||
*/
|
||||
export function generateSlotStarts(
|
||||
freeWindows: TimeWindow[],
|
||||
params: { slotIntervalMinutes: number; durationMinutes: number },
|
||||
): number[] {
|
||||
const { slotIntervalMinutes, durationMinutes } = params;
|
||||
if (slotIntervalMinutes <= 0) throw new Error("slotIntervalMinutes must be positive");
|
||||
if (durationMinutes <= 0) throw new Error("durationMinutes must be positive");
|
||||
|
||||
const starts: number[] = [];
|
||||
|
||||
for (const window of freeWindows) {
|
||||
const firstGridPoint = Math.ceil(window.startMinutes / slotIntervalMinutes) * slotIntervalMinutes;
|
||||
for (let start = firstGridPoint; start + durationMinutes <= window.endMinutes; start += slotIntervalMinutes) {
|
||||
starts.push(start);
|
||||
}
|
||||
}
|
||||
|
||||
return starts;
|
||||
}
|
||||
|
||||
/** Expand an appointment's [start, end] into a buffer-padded busy window, clamped to a single day. */
|
||||
export function expandWithBuffer(
|
||||
appointment: TimeWindow,
|
||||
buffer: { beforeMinutes: number; afterMinutes: number },
|
||||
): TimeWindow {
|
||||
return {
|
||||
startMinutes: clamp(appointment.startMinutes - buffer.beforeMinutes),
|
||||
endMinutes: clamp(appointment.endMinutes + buffer.afterMinutes),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import {
|
||||
calendarDateToUtcMidnight,
|
||||
eachCalendarDate,
|
||||
endOfBusinessDay,
|
||||
minutesFromMidnight,
|
||||
startOfBusinessDay,
|
||||
weekdayOf,
|
||||
} from "@/lib/time/business-time";
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
import { computeFreeWindows, expandWithBuffer } from "./slot-calculator";
|
||||
import type { TimeWindow } from "./types";
|
||||
|
||||
/**
|
||||
* A single staff member's availability — recurring working hours,
|
||||
* recurring breaks, approved time off, and existing appointments
|
||||
* (buffer-expanded). Deliberately does NOT know about business opening
|
||||
* hours or requested services; those are AvailabilityService's job to
|
||||
* fetch and combine. This keeps "what does this barber's own schedule
|
||||
* look like" separate from "what can a customer actually book".
|
||||
*
|
||||
* `getStaffFreeWindowsForRange` is the primitive both other exports build
|
||||
* on: it fetches each of WorkingHours/Breaks/TimeOff/Appointments exactly
|
||||
* once for the whole date range (not once per day), because a 60-day
|
||||
* "which dates have openings" scan calling a single-day query in a loop
|
||||
* would be 4 DB round-trips × 60 days × N staff — the range-batched
|
||||
* version is 4 round-trips × N staff, full stop.
|
||||
*/
|
||||
|
||||
export interface BusinessOpenByWeekday {
|
||||
get(weekday: Weekday): TimeWindow | null;
|
||||
}
|
||||
|
||||
export function businessOpenByWeekdayFrom(
|
||||
hours: { dayOfWeek: Weekday; isClosed: boolean; opensAtMinutes: number | null; closesAtMinutes: number | null }[],
|
||||
): BusinessOpenByWeekday {
|
||||
const map = new Map<Weekday, TimeWindow | null>();
|
||||
for (const h of hours) {
|
||||
map.set(
|
||||
h.dayOfWeek,
|
||||
!h.isClosed && h.opensAtMinutes !== null && h.closesAtMinutes !== null
|
||||
? { startMinutes: h.opensAtMinutes, endMinutes: h.closesAtMinutes }
|
||||
: null,
|
||||
);
|
||||
}
|
||||
return { get: (weekday) => map.get(weekday) ?? null };
|
||||
}
|
||||
|
||||
export interface GetStaffFreeWindowsForRangeParams {
|
||||
staffId: string;
|
||||
fromDate: CalendarDate;
|
||||
toDate: CalendarDate;
|
||||
businessTimezone: string;
|
||||
businessOpenByWeekday: BusinessOpenByWeekday;
|
||||
effectiveBuffer: { beforeMinutes: number; afterMinutes: number };
|
||||
excludeAppointmentId?: string;
|
||||
}
|
||||
|
||||
export async function getStaffFreeWindowsForRange(
|
||||
prisma: PrismaClient,
|
||||
params: GetStaffFreeWindowsForRangeParams,
|
||||
): Promise<Map<CalendarDate, TimeWindow[]>> {
|
||||
const { staffId, fromDate, toDate, businessTimezone, businessOpenByWeekday, effectiveBuffer, excludeAppointmentId } =
|
||||
params;
|
||||
|
||||
const rangeStart = startOfBusinessDay(fromDate, businessTimezone);
|
||||
const rangeEnd = endOfBusinessDay(toDate, businessTimezone);
|
||||
|
||||
const [workingHoursRows, breakDays, timeOffRows, appointments] = await Promise.all([
|
||||
prisma.workingHours.findMany({ where: { staffId } }),
|
||||
prisma.breakDay.findMany({ where: { break: { staffId } }, include: { break: true } }),
|
||||
prisma.timeOff.findMany({
|
||||
where: {
|
||||
staffId,
|
||||
status: "APPROVED",
|
||||
startDate: { lte: calendarDateToUtcMidnight(toDate) },
|
||||
endDate: { gte: calendarDateToUtcMidnight(fromDate) },
|
||||
},
|
||||
}),
|
||||
prisma.appointment.findMany({
|
||||
where: {
|
||||
staffId,
|
||||
status: { notIn: ["CANCELLED", "NO_SHOW"] },
|
||||
...(excludeAppointmentId ? { id: { not: excludeAppointmentId } } : {}),
|
||||
startAt: { lt: rangeEnd },
|
||||
endAt: { gt: rangeStart },
|
||||
},
|
||||
select: { startAt: true, endAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const workingHoursByWeekday = new Map(workingHoursRows.map((wh) => [wh.dayOfWeek, wh]));
|
||||
const breaksByWeekday = new Map<Weekday, TimeWindow[]>();
|
||||
for (const bd of breakDays) {
|
||||
const list = breaksByWeekday.get(bd.dayOfWeek) ?? [];
|
||||
list.push({ startMinutes: bd.break.startMinutes, endMinutes: bd.break.endMinutes });
|
||||
breaksByWeekday.set(bd.dayOfWeek, list);
|
||||
}
|
||||
|
||||
const result = new Map<CalendarDate, TimeWindow[]>();
|
||||
|
||||
for (const date of eachCalendarDate(fromDate, toDate)) {
|
||||
const weekday = weekdayOf(date);
|
||||
const businessOpen = businessOpenByWeekday.get(weekday);
|
||||
|
||||
if (!businessOpen) {
|
||||
result.set(date, []);
|
||||
continue;
|
||||
}
|
||||
|
||||
const wh = workingHoursByWeekday.get(weekday);
|
||||
const staffWorking: TimeWindow | null =
|
||||
wh && !wh.isOff && wh.startMinutes !== null && wh.endMinutes !== null
|
||||
? { startMinutes: wh.startMinutes, endMinutes: wh.endMinutes }
|
||||
: null;
|
||||
|
||||
const dateAsUtcMidnight = calendarDateToUtcMidnight(date);
|
||||
const onLeave = timeOffRows.some((t) => t.startDate <= dateAsUtcMidnight && t.endDate >= dateAsUtcMidnight);
|
||||
|
||||
const dayStart = startOfBusinessDay(date, businessTimezone);
|
||||
const dayEnd = endOfBusinessDay(date, businessTimezone);
|
||||
const busy: TimeWindow[] = appointments
|
||||
.filter((a) => a.startAt < dayEnd && a.endAt > dayStart)
|
||||
.map((a) =>
|
||||
expandWithBuffer(
|
||||
{
|
||||
startMinutes: minutesFromMidnight(a.startAt, businessTimezone),
|
||||
endMinutes: minutesFromMidnight(a.endAt, businessTimezone),
|
||||
},
|
||||
effectiveBuffer,
|
||||
),
|
||||
);
|
||||
|
||||
result.set(
|
||||
date,
|
||||
computeFreeWindows({
|
||||
businessOpen,
|
||||
staffWorking,
|
||||
breaks: breaksByWeekday.get(weekday) ?? [],
|
||||
onLeave,
|
||||
busy,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface GetStaffFreeWindowsParams {
|
||||
staffId: string;
|
||||
date: CalendarDate;
|
||||
businessTimezone: string;
|
||||
/** Business opening hours for this date, already resolved by the caller — null if the business is closed that day. */
|
||||
businessOpen: TimeWindow | null;
|
||||
effectiveBuffer: { beforeMinutes: number; afterMinutes: number };
|
||||
excludeAppointmentId?: string;
|
||||
}
|
||||
|
||||
/** Single-day convenience wrapper around getStaffFreeWindowsForRange. */
|
||||
export async function getStaffFreeWindows(prisma: PrismaClient, params: GetStaffFreeWindowsParams): Promise<TimeWindow[]> {
|
||||
const { staffId, date, businessTimezone, businessOpen, effectiveBuffer, excludeAppointmentId } = params;
|
||||
const weekday = weekdayOf(date);
|
||||
const byWeekday: BusinessOpenByWeekday = { get: (w) => (w === weekday ? businessOpen : null) };
|
||||
|
||||
const map = await getStaffFreeWindowsForRange(prisma, {
|
||||
staffId,
|
||||
fromDate: date,
|
||||
toDate: date,
|
||||
businessTimezone,
|
||||
businessOpenByWeekday: byWeekday,
|
||||
effectiveBuffer,
|
||||
excludeAppointmentId,
|
||||
});
|
||||
|
||||
return map.get(date) ?? [];
|
||||
}
|
||||
|
||||
/** How many non-cancelled appointments this staff member has on a given calendar date — used for the per-staff daily cap. */
|
||||
export async function countStaffAppointmentsOnDate(
|
||||
prisma: PrismaClient,
|
||||
params: { staffId: string; date: CalendarDate; businessTimezone: string; excludeAppointmentId?: string },
|
||||
): Promise<number> {
|
||||
const { staffId, date, businessTimezone, excludeAppointmentId } = params;
|
||||
return prisma.appointment.count({
|
||||
where: {
|
||||
staffId,
|
||||
status: { notIn: ["CANCELLED", "NO_SHOW"] },
|
||||
...(excludeAppointmentId ? { id: { not: excludeAppointmentId } } : {}),
|
||||
startAt: { gte: startOfBusinessDay(date, businessTimezone), lt: endOfBusinessDay(date, businessTimezone) },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
|
||||
/** A span within a single day, in minutes since local midnight (0–1440). Never crosses a day boundary — see slot-calculator.ts header comment. */
|
||||
export interface TimeWindow {
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
}
|
||||
|
||||
/** Minutes-from-midnight open/close for one day, or null if closed/off entirely. */
|
||||
export interface DayHours {
|
||||
isOpen: boolean;
|
||||
window: TimeWindow | null;
|
||||
}
|
||||
|
||||
export interface SlotCandidate {
|
||||
date: CalendarDate;
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
}
|
||||
|
||||
export interface ServiceRequirement {
|
||||
serviceId: string;
|
||||
durationMinutes: number;
|
||||
/** Buffer this specific service needs, on top of the business-wide default — see business-rules.service.ts for how the two combine. */
|
||||
bufferBeforeMinutes: number;
|
||||
bufferAfterMinutes: number;
|
||||
/** Decimal serialized to string, snapshotted onto AppointmentService.priceAtBooking at booking time. */
|
||||
price: string;
|
||||
}
|
||||
|
||||
export const BOOKING_VALIDATION_ERROR_CODES = [
|
||||
"INVALID_INPUT",
|
||||
"NO_SERVICES_SELECTED",
|
||||
"SERVICES_SPAN_MULTIPLE_BUSINESSES",
|
||||
"STAFF_CANNOT_PERFORM_SERVICE",
|
||||
"BUSINESS_CLOSED",
|
||||
"STAFF_NOT_WORKING",
|
||||
"STAFF_ON_BREAK",
|
||||
"STAFF_ON_LEAVE",
|
||||
"SLOT_UNAVAILABLE",
|
||||
"INSUFFICIENT_DURATION",
|
||||
"BELOW_MIN_NOTICE",
|
||||
"BEYOND_MAX_ADVANCE",
|
||||
"BUSINESS_DAILY_LIMIT_REACHED",
|
||||
"STAFF_DAILY_LIMIT_REACHED",
|
||||
"APPOINTMENT_NOT_FOUND",
|
||||
"INVALID_STATUS_TRANSITION",
|
||||
"STAFF_NOT_FOUND",
|
||||
"CUSTOMER_NOT_FOUND",
|
||||
"BUSINESS_NOT_FOUND",
|
||||
] as const;
|
||||
|
||||
export type BookingValidationErrorCode = (typeof BOOKING_VALIDATION_ERROR_CODES)[number];
|
||||
|
||||
export interface ValidationIssue {
|
||||
code: BookingValidationErrorCode;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
issues: ValidationIssue[];
|
||||
}
|
||||
|
||||
export function ok(): ValidationResult {
|
||||
return { valid: true, issues: [] };
|
||||
}
|
||||
|
||||
export function fail(...issues: ValidationIssue[]): ValidationResult {
|
||||
return { valid: false, issues };
|
||||
}
|
||||
|
||||
export function mergeValidation(...results: ValidationResult[]): ValidationResult {
|
||||
const issues = results.flatMap((r) => r.issues);
|
||||
return { valid: issues.length === 0, issues };
|
||||
}
|
||||
Reference in new issue
Block a user