# 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.