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:
Stefan Lazic committed 2026-08-02 18:36:39 +01:00
1 parent 090646fdfd
commit 8b63cc66a9
28 files changed
+4853 -15

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
# NextChair — Phase 3 Summary
### Booking & Scheduling Engine
Status: **Complete and verified.** Typecheck, lint, and `next build` are clean. 59 unit tests and 24 integration tests (against a real, throwaway Postgres) pass, including a genuine concurrency test that fires two simultaneous booking requests at the same slot and asserts exactly one succeeds. Per your instructions, no customer booking pages, barber calendar UI, owner dashboard, loyalty UI, or notification UI were built — the only UI is one unstyled internal test harness at `/dev/scheduling-test`, used to manually exercise the engine end-to-end in a real browser.
---
## 1. Architecture: six services, not one
Your brief's `BookingService` and `SchedulingService` descriptions overlapped ("coordinates the booking workflow" vs. "coordinates availability and appointments, prevents double bookings"). I resolved that by giving `SchedulingService` one narrow, specific job — the transactional commit — and having `BookingService` own the workflow around it:
```
src/services/scheduling/
slot-calculator.ts # pure, DB-free window math (the core algorithm)
business-rules.service.ts # pure BookingRule evaluation (notice, buffers, caps)
staff-availability.service.ts # DB: one staff member's free windows for a day/range
availability.service.ts # DB: customer-facing queries + full slot diagnosis
scheduling.service.ts # DB: the ONE place a booking is actually committed
booking.service.ts # DB: create/reschedule/cancel workflow orchestration
appointment.service.ts # DB: retrieval + status-transition state machine
types.ts # shared TimeWindow/ValidationResult vocabulary
```
| Service | Owns | Does NOT own |
|---|---|---|
| `BusinessRulesService` | Evaluating one rule at a time (notice, advance window, daily caps, effective buffer) against plain inputs | Fetching anything from the database |
| `StaffAvailabilityService` | One staff member's free time windows (working hours ∩ business hours − breaks − leave − buffered appointments) | Business hours themselves, requested services, or other staff |
| `AvailabilityService` | Combining staff + services + rules into "what can a customer actually book" (dates/slots/barbers/services), and diagnosing *why* one specific candidate slot fails | Committing anything |
| `SchedulingService` | The transactional double-booking guard — nothing else | Validation, workflow, business rules |
| `BookingService` | The create/reschedule/cancel workflow: call Availability to validate, call Scheduling to commit, pick a staff member for "any barber" | Availability math or conflict detection itself |
| `AppointmentService` | Retrieval and the status-transition state machine (check-in/start/complete/no-show) | Anything about booking, rescheduling, or availability |
`slot-calculator.ts` is deliberately the only place with real algorithmic complexity, and it's 100% pure — no Prisma import at all — which is why it carries the bulk of the unit tests (24 of the 59).
---
## 2. Availability is never stored — verified, not just asserted
Every availability answer (`getAvailableDates`, `getAvailableTimeSlots`, `getAvailableStaff`) is computed live from `BusinessHours` + `WorkingHours` + `Break`/`BreakDay` + `TimeOff` + non-cancelled `Appointment` rows on every call. There is no cache table, no materialized slot list, nothing to go stale. The cost of that is real DB round-trips per query — `getStaffFreeWindowsForRange` batches all four lookups per staff member into one round-trip *for the whole requested date range* (not once per day) specifically so a 60-day "which dates have openings" scan doesn't turn into hundreds of queries; see the comment in `staff-availability.service.ts`.
---
## 3. Prisma schema changes
Kept deliberately minimal, per your instruction:
- **`AppointmentStatus` enum renamed** to match your exact list: `PENDING_APPROVAL → PENDING`, `ARRIVED → CHECKED_IN` (the rest — `CONFIRMED`, `IN_PROGRESS`, `COMPLETED`, `CANCELLED`, `NO_SHOW` — already matched).
- **`Appointment` gained cancellation-tracking fields**: `cancelledAt`, `cancelledByUserId` (→ `User`, nullable, `onDelete: SetNull`). `cancellationReason` already existed from Phase 2.
- **`BookingRule` gained `maxAppointmentsPerStaffPerDay`** (`Int?`), distinct from the existing business-wide `maxAppointmentsPerDay`.
No other models changed. The migration (`prisma/migrations/20260802165351_booking_engine/`) was generated non-interactively via `prisma migrate diff` (Prisma 7 removed the classic `migrate dev --create-only` path used by CI-style, non-interactive environments — see the "Prisma 7, again" note below) and applied + verified against a real database.
---
## 4. The status transition state machine
```
PENDING ──→ CONFIRMED ──→ CHECKED_IN ──→ IN_PROGRESS ──→ COMPLETED
│ │ │
↓ ↓ ↓
CANCELLED CANCELLED CANCELLED
│
↓
NO_SHOW
```
`NO_SHOW` is reachable only from `CONFIRMED` — once an appointment is `CHECKED_IN`, the customer demonstrably showed up, so "no-show" no longer makes sense as a next state. `COMPLETED`, `CANCELLED`, and `NO_SHOW` are terminal — nothing transitions out of them. This is enforced by `appointment.service.ts`'s `canTransition`/`validateTransition`, applied inside a transaction in every status-changing call, and has full unit-test coverage of every legal and illegal transition (`appointment.service.test.ts`).
---
## 5. Validation — specific, not generic
Your brief listed seven distinct things to check before a booking, and asked for "meaningful validation errors" — so `AvailabilityService.validateBookingSlot` diagnoses a candidate slot in a specific order, each with its own error code, rather than one generic "unavailable":
`BUSINESS_CLOSED` → `STAFF_NOT_WORKING` → `STAFF_ON_LEAVE` → `STAFF_ON_BREAK` → `INSUFFICIENT_DURATION` (the services genuinely don't fit in the remaining open time, computed *without* existing appointments) → `BUSINESS_DAILY_LIMIT_REACHED` / `STAFF_DAILY_LIMIT_REACHED` → `SLOT_UNAVAILABLE` (fits in principle, but something else is already booked there). Plus `BELOW_MIN_NOTICE` and `BEYOND_MAX_ADVANCE`, checked alongside the rest. All 18 codes are in `types.ts` as a literal union, not a loose string, so a caller can exhaustively switch on them.
This diagnosis is a best-effort read for a good error message — it can be (rarely) stale by the time a booking actually commits, same as any read-then-write. The transactional commit in `SchedulingService` is what's actually authoritative.
---
## 6. Double-booking prevention — the part I verified hardest
Two layers, both necessary:
1. **An explicit overlap re-check**, inside the transaction, against the target staff member's other non-cancelled appointments — mirrors the exact buffer logic `AvailabilityService` uses, so a slot that was offered can't be rejected here for a *different* reason than why it wouldn't have been offered.
2. **`SERIALIZABLE` transaction isolation** (`Prisma.TransactionIsolationLevel.Serializable`) as the real guarantee — standard SQL, supported by both Postgres and MySQL (unlike a Postgres-only exclusion constraint, which would have broken your database-portability goal). Under a genuine race, Postgres itself rejects one of the two concurrent transactions with a serialization failure; my code catches that and turns it into the same `SLOT_UNAVAILABLE` response a normal validation failure would produce — the caller never sees a raw database error.
**This is the one place in the whole phase where I found and fixed a real bug by actually testing it, not by reasoning about it.** My first version of the double-booking test passed, but a *later* full-suite run failed intermittently with an unhandled `DriverAdapterError`. Prisma 7's new TypeScript-native client-engine-runtime (used because of the driver-adapter architecture — see below) propagates a serialization conflict as the driver adapter's own `DriverAdapterError` (`cause.kind === "TransactionWriteConflict"`, Postgres SQLSTATE `40001`), not as the classic `PrismaClientKnownRequestError` with code `P2034` that my first version only checked for. Whichever of the two competing transactions happened to lose the race *inside* my own application-level overlap check got a clean `DoubleBookingError`; the one that lost the race at the *database* level (a true concurrent-write conflict my query-level check couldn't see) crashed instead. I only found this because I ran the integration suite multiple times rather than trusting one green run — it's now fixed to catch both error shapes and re-verified clean across 5 consecutive runs. See the comment in `scheduling.service.ts`.
---
## 7. Assumptions made this phase
- **Effective buffer = business-wide baseline + the largest requested service's own buffer** (not a replacement, and not summed across every service in a multi-service booking — just the max). A colour treatment's extra processing buffer stacks on top of the shop's standard gap; two services in one appointment don't double that extra buffer.
- **"Update booking" (from your API list) means notes only.** Time or staff changes go through `rescheduleBooking` instead, which is a distinct, fully re-validated workflow — letting a generic "update" silently move an appointment's time without the same validation would undermine the whole point of having a separate reschedule path.
- **Cancellation is never blocked by the cancellation-window rule.** `cancelBooking` always succeeds (down to the appointment's own status making it eligible) and returns `wasLateCancellation: boolean` so a caller can show "this was a late cancellation" — refusing to let someone cancel an appointment they don't want seemed like the wrong call in the absence of any payments/fees system to actually enforce a penalty.
- **"Any available barber" picks whoever has the fewest appointments that day** among staff free at the requested slot, to spread bookings out rather than always favouring the first staff member in the list. A more sophisticated strategy (least-idle-time, staff preference weighting) is a reasonable Phase-5-or-later refinement, not a Phase 3 concern.
- **A day never crosses midnight.** All window math clamps to `[0, 1440]` minutes; a barbershop open past midnight isn't modelled. Reasonable for the domain, worth confirming before this ever needs to change.
- **Guest customers aren't created by this phase.** `BookingService.createBooking` requires an existing `customerId` — resolving *who* is booking (guest-details capture, account matching) is explicitly a Phase 1/booking-UI concern per the implementation plan, not this engine's job.
---
## 8. A Prisma 7 detail worth flagging again
Generating a schema migration *non-interactively* changed in Prisma 7: `prisma migrate dev` refuses to run at all outside a real TTY once it detects a diff it would normally ask you to confirm (here, the `AppointmentStatus` enum rename). The CI-safe replacement is `prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script`, writing the output into a manually-created migration folder, then applying it with `prisma migrate deploy`. Both are non-interactive by design. Documented here because it's the kind of thing that looks like a broken tool the first time you hit it.
---
## 9. What was actually verified (not just built)
1. **59 unit tests**, zero database — `slot-calculator.ts` (window intersection/subtraction/merging, grid-aligned slot generation), `business-rules.service.ts` (every rule check plus edge-of-boundary cases), `appointment.service.ts` (every legal and illegal status transition), and `business-time.ts` (including two tests that assert *specific* UTC instants either side of the actual 2026 UK daylight-saving transition — a naive "always UTC+0" implementation would fail these).
2. **24 integration tests**, real Postgres (a throwaway Docker container, migrated, torn down afterward) — availability calculation, business-closed days, staff working hours, breaks, time off (both "blocks the day" and "doesn't block an unrelated day"), multi-service duration combination and price/duration snapshotting, buffer enforcement, every booking-rule validation code, double-booking rejection (both a simple sequential case and the real concurrency case), rescheduling (including "doesn't conflict with its own original slot" and "rejects a completed appointment"), and cancellation (status/fields set correctly, never deletes, frees the slot, rejects cancelling something already completed).
3. **Manually, in a real browser**, against the same throwaway database: opened `/dev/scheduling-test`, selected a service, saw the calendar correctly gray out Sundays (business closed) and highlight real openings, selected a date, saw real time slots generated (10:00–18:30, matching the seeded business hours and 30-minute grid), booked one with "Anyone available" (which correctly auto-assigned a specific barber), and drove it through Check In (confirmed the status pill updated to "Arrived" after a real server round-trip).
## 10. Running it yourself
```bash
npm run test # 59 unit tests, no database needed
npm run test:integration # 24 integration tests — needs DATABASE_URL pointed at a real Postgres
npm run dev # then visit /dev/scheduling-test
```
---
Stopping here as instructed — no customer booking pages, barber calendar, owner dashboard, loyalty UI, or notifications. Ready for direction on what's next.
+1101 -1
View File
File diff suppressed because it is too large. Load diff
+8 -2
View File
@@ -13,7 +13,10 @@
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio", "prisma:studio": "prisma studio",
"prisma:seed": "tsx prisma/seed.ts" "prisma:seed": "tsx prisma/seed.ts",
"test": "vitest run",
"test:watch": "vitest",
"test:integration": "vitest run --config vitest.integration.config.mts"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -34,6 +37,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.28.0", "lucide-react": "^1.28.0",
"luxon": "^3.7.2",
"next": "^16.2.12", "next": "^16.2.12",
"next-auth": "^5.0.0-beta.32", "next-auth": "^5.0.0-beta.32",
"pg": "^8.22.0", "pg": "^8.22.0",
@@ -46,6 +50,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.3.3", "@tailwindcss/postcss": "^4.3.3",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/luxon": "^3.7.3",
"@types/node": "^26.1.2", "@types/node": "^26.1.2",
"@types/pg": "^8.20.3", "@types/pg": "^8.20.3",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
@@ -59,6 +64,7 @@
"prisma": "^7.9.1", "prisma": "^7.9.1",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"tsx": "^4.23.4", "tsx": "^4.23.4",
"typescript": "^6.0.3" "typescript": "^6.0.3",
"vitest": "^4.1.10"
} }
} }
@@ -0,0 +1,21 @@
-- AlterEnum
BEGIN;
CREATE TYPE "AppointmentStatus_new" AS ENUM ('PENDING', 'CONFIRMED', 'CHECKED_IN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'NO_SHOW');
ALTER TABLE "public"."Appointment" ALTER COLUMN "status" DROP DEFAULT;
ALTER TABLE "Appointment" ALTER COLUMN "status" TYPE "AppointmentStatus_new" USING ("status"::text::"AppointmentStatus_new");
ALTER TYPE "AppointmentStatus" RENAME TO "AppointmentStatus_old";
ALTER TYPE "AppointmentStatus_new" RENAME TO "AppointmentStatus";
DROP TYPE "public"."AppointmentStatus_old";
ALTER TABLE "Appointment" ALTER COLUMN "status" SET DEFAULT 'CONFIRMED';
COMMIT;
-- AlterTable
ALTER TABLE "Appointment" ADD COLUMN "cancelledAt" TIMESTAMP(3),
ADD COLUMN "cancelledByUserId" TEXT;
-- AlterTable
ALTER TABLE "BookingRule" ADD COLUMN "maxAppointmentsPerStaffPerDay" INTEGER;
-- AddForeignKey
ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_cancelledByUserId_fkey" FOREIGN KEY ("cancelledByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+31 -12
View File
@@ -61,10 +61,16 @@ enum StaffStatus {
INACTIVE INACTIVE
} }
/// PENDING -> CONFIRMED -> CHECKED_IN -> IN_PROGRESS -> COMPLETED is the
/// happy path. CANCELLED is reachable from PENDING/CONFIRMED/CHECKED_IN.
/// NO_SHOW is reachable only from CONFIRMED (once CHECKED_IN, the customer
/// did show up). COMPLETED/CANCELLED/NO_SHOW are terminal. The transition
/// map itself lives in services/scheduling/appointment.service.ts, not
/// here — this enum is just the vocabulary.
enum AppointmentStatus { enum AppointmentStatus {
PENDING_APPROVAL PENDING
CONFIRMED CONFIRMED
ARRIVED CHECKED_IN
IN_PROGRESS IN_PROGRESS
COMPLETED COMPLETED
CANCELLED CANCELLED
@@ -226,15 +232,19 @@ model BookingRule {
businessId String @unique businessId String @unique
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
minNoticeMinutes Int @default(120) minNoticeMinutes Int @default(120)
maxAdvanceDays Int @default(60) maxAdvanceDays Int @default(60)
cancellationWindowHours Int @default(24) cancellationWindowHours Int @default(24)
rescheduleWindowHours Int @default(24) rescheduleWindowHours Int @default(24)
slotIntervalMinutes Int @default(30) slotIntervalMinutes Int @default(30)
bufferBeforeMinutes Int @default(0) bufferBeforeMinutes Int @default(0)
bufferAfterMinutes Int @default(5) bufferAfterMinutes Int @default(5)
/// null = no daily cap. /// null = no daily cap. Business-wide total across all staff.
maxAppointmentsPerDay Int? maxAppointmentsPerDay Int?
/// null = no per-staff cap. Distinct from maxAppointmentsPerDay — this
/// limits how many appointments any single staff member can take in a
/// day, independent of the shop-wide total.
maxAppointmentsPerStaffPerDay Int?
customerCanChooseStaff Boolean @default(true) customerCanChooseStaff Boolean @default(true)
allowGuestBooking Boolean @default(true) allowGuestBooking Boolean @default(true)
@@ -281,6 +291,9 @@ model User {
staffProfile StaffProfile? staffProfile StaffProfile?
notifications Notification[] notifications Notification[]
notificationPreferences NotificationPreference[] notificationPreferences NotificationPreference[]
/// Whoever actually cancelled an appointment — the customer themselves,
/// or the staff member who did it on their behalf.
cancelledAppointments Appointment[] @relation("AppointmentCancelledBy")
} }
model Account { model Account {
@@ -577,8 +590,14 @@ model Appointment {
startAt DateTime startAt DateTime
endAt DateTime endAt DateTime
notes String? @db.Text notes String? @db.Text
/// Cancellation is a status transition, never a delete — these three
/// fields are only ever set together, when status becomes CANCELLED.
cancellationReason String? cancellationReason String?
cancelledAt DateTime?
cancelledByUserId String?
cancelledByUser User? @relation("AppointmentCancelledBy", fields: [cancelledByUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
+55
View File
@@ -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);
}
+56
View File
@@ -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 };
}
+60
View File
@@ -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}$/);
});
});
+120
View File
@@ -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;
}
+247
View File
@@ -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 },
});
});
}
+157
View File
@@ -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) },
},
});
}
+76
View File
@@ -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 };
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: { tsconfigPaths: true },
test: {
environment: "node",
globals: false,
// Unit tests (no DB) run by default via `npm test`. Integration tests
// (real Postgres required) are named `*.integration.test.ts` and only
// run via `npm run test:integration` — see docs/phase-3-summary.md.
exclude: ["node_modules/**", ".next/**", "templates/**", "**/*.integration.test.ts"],
},
});
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: { tsconfigPaths: true },
test: {
environment: "node",
globals: false,
include: ["**/*.integration.test.ts"],
exclude: ["node_modules/**", ".next/**", "templates/**"],
// Concurrency tests (double-booking prevention) need real, unshared
// DB connections and predictable ordering — run integration test
// files one at a time.
fileParallelism: false,
testTimeout: 20000,
},
});