# Phase 5 — Barber Operations: Summary The full barber-facing operational experience, in the same application as the customer app: schedule, current appointment, appointment actions, customer directory, notes, book-for-customer, follow-up booking, loyalty stamps, working hours/breaks/time off, block time, profile, and notification preferences. No separate staff app — the existing auth, role/permission system, `(staff)` route group, and design system are reused throughout; only two genuinely new domain services (`StaffService`, `LoyaltyService`) were added. --- ## 1. What was built - **Barber Calendar** (`/staff`) — Day and Agenda views, date strip (prev/next/today/7-day), current-time indicator, working hours/breaks/time off/appointments all rendered, "Block Time" and "New" (book) actions. Desktop adds a "Right Now" side panel showing the current appointment beside the calendar (§10). - **Current Appointment** (`/staff/current`) — priority-tiered selection (IN_PROGRESS → CHECKED_IN → scheduled-now → next-upcoming-today → empty state), full detail card, status actions, add-stamp, book-follow-up. - **Appointment Details + status actions** (`/staff/appointments/[id]`) — reference/customer/services/time/price/booking notes/customer notes/loyalty activity/cancellation info; every action routed through `AppointmentService`'s existing state machine, never a direct Prisma status write. - **Reschedule** (`/staff/appointments/[id]/reschedule`) — reuses the same `BarberDateTimePicker` as the customer flow, outside the `(staff)` route group for the same fixed-bottom-bar-vs-nav reason Phase 4 documented. - **Customers** (`/staff/clients`) — search, alphabetical/recent sort, visit count, upcoming/loyalty indicators; 2-column grid on desktop. - **Customer Details** (`/staff/clients/[id]`) — profile, contact, loyalty (stamps/reward/history/add/correct), Upcoming/Past/Cancelled tabs, notes; 2-column layout on desktop (profile+loyalty rail beside appointments+notes). - **Customer Notes** — add (any staff), edit/delete (author only), backed by `CustomerNote`, never surfaced on any customer-facing route. - **Book for Customer** (`/staff/book/*`) — select an existing customer or add a new guest, then the *same* `ServiceSelector`/`BarberDateTimePicker` components the customer `/book` flow uses, generalized to accept an injected continuation callback instead of a hardcoded action. - **Follow-Up Booking** — preselects customer/services/barber from a past visit; prices, durations, and availability are always recalculated fresh, never copied. - **Loyalty Stamps** — view progress, add (manual, with optional reason), auto-award on completion (idempotent per appointment), correct (always a compensating removal, reason required, clamped at zero), reward creation at threshold (never stacked). - **Working Hours, Breaks, Time Off, Block Time** (`/staff/settings/*`) — per-day hours with copy-to-all-days; recurring breaks by day-of-week; whole-day or partial-day time off; Block Time is a one-off partial-day `TimeOff` row, not a separate model (§5). All three mutation paths detect appointment conflicts and require explicit "Save/Block Anyway" acknowledgement — never a silent cancellation. - **Barber Profile** (`/staff/settings/profile`) — name/phone/photo/bio/public-visibility, editable; services offered shown read-only ("only the owner can change this"). - **Notification Preferences** (`/staff/settings/notifications`) — a category × channel grid over the existing `NotificationPreference` model; UI only, no delivery wired up. ## 2. Routes created Inside `(staff)` (persistent nav via `StaffShell`, §10): `/staff` (calendar), `/staff/current`, `/staff/appointments/[id]`, `/staff/clients`, `/staff/clients/[id]`, `/staff/settings`, `/staff/settings/working-hours`, `/staff/settings/breaks`, `/staff/settings/time-off`, `/staff/settings/profile`, `/staff/settings/notifications`. Outside `(staff)` (own fixed-bottom-bar UI, no persistent nav — same route-group-splitting pattern as Phase 4's `/book`): `/staff/appointments/[id]/reschedule`, `/staff/book`, `/staff/book/services`, `/staff/book/barber-time`, `/staff/book/review`. ## 3. Domain services **Reused as-is:** `AvailabilityService`, `BookingService`, `AppointmentService` (gained one pure addition, `selectCurrentAppointment`), `SchedulingService` (gained `createdByUserId` provenance threading), `StaffAvailabilityService`, `BusinessRulesService`, `CustomerService`. **New — `StaffService`** (`src/services/staff/staff.service.ts`): staff's own profile edit, working hours, breaks (create/update/delete + conflict detection), time off (same, plus the whole-day-vs-partial-day distinction), notification preferences. Deliberately does *not* touch availability calculation or appointment status — it's the write side of data `StaffAvailabilityService`/`AvailabilityService` read. **New — `LoyaltyService`** (`src/services/loyalty/loyalty.service.ts`): account lookup/creation, eligibility checks (staff/service allow-lists, empty list = everyone eligible), stamp award (auto + manual), correction, reward creation. Ledger-based throughout — `LoyaltyAccount.currentStamps` is only ever written inside the same transaction as a new `LoyaltyTransaction` row. **New (feature-layer, not domain services)** — `src/features/staff/authorization.ts` (own-business / own-appointment / own-note checks, mirroring Phase 4's customer `authorization.ts`) and `src/lib/staff/get-acting-staff.ts` (request-deduped current-staff lookup). ## 4. Prisma schema changes - `Appointment.createdByUserId` (nullable, `SetNull`) — who initiated a booking (self-service customer, or the staff member if created on their behalf). Provenance only; never used in a permission check. - `TimeOff.startMinutes` / `TimeOff.endMinutes` (both nullable) — both null = whole-day (the pre-Phase-5 behaviour, every existing row keeps this); both set = a partial-day block, on every day in `[startDate, endDate]`. This is also how **Block Time** is represented: a `TimeOff` row with `startDate = endDate` and a short reason, not a separate model. - `CustomerNote.updatedAt` (`@updatedAt`) — the note-edit signal; a staff member may edit/delete only their own note (`assertOwnNote`), and edits/deletes are additionally recorded in the existing (previously-unused) `AuditLog` table. Migration: `20260802204707_staff_operations`. ## 5. Notable design decisions - **Block Time reuses `TimeOff`, not a new model.** A one-off blocked slot and "real" time off are the same shape (a period a barber isn't bookable); giving them one editor and one conflict-detection path was more honest than inventing parallel machinery for a distinction the data doesn't actually need. - **`ServiceSelector` and `AppointmentCard` were generalized, not forked.** `ServiceSelector` traded its hardcoded Server Action import for an injected `onContinue` callback so the same component drives both `/book` and `/staff/book`. `AppointmentCard` gained a generic `person`/`personLabel` (was `staff`) plus optional `hasNotes`/`hasAvailableReward` so it serves customer, staff-calendar, and staff-customer-detail contexts alike. Both changes touched their existing Phase 4 call sites; verified via `tsc`/`eslint`/`build` immediately after each. - **Two independent signed cookies for booking drafts.** `nc_staff_booking_draft` (new) is separate from the customer's `nc_booking_draft` — a barber's own personal in-progress booking (if they ever use `/book` themselves) can never collide with a "book for customer" draft they're building at the same time. - **`selectCurrentAppointment` is a pure function** (`appointment.service.ts`) over an already-fetched list, taking `now` as a parameter — cheap to unit-test exhaustively without a database (see `appointment.service.test.ts`). - **Notification preferences default to all-on** with no seeded rows, matching the schema's own `isEnabled @default(true)` — a user who's never touched the settings page still sees an accurate, fully-populated grid rather than an empty one. - **Customer-list business scoping is via the appointment relation**, not a `CustomerProfile.businessId` column (there isn't one — single-business v1, same assumption Phase 4 documented). `appointments: { some: { businessId } }` is a real boundary even though it's currently redundant in a single-business deployment. ## 6. Permission enforcement Every mutation re-derives the acting staff member server-side (`getActingStaff()`, never trusts a client-supplied id) and re-checks ownership at the point of the mutation, not just at the page level: - **Own-business, own-appointment**: `assertAppointmentInBusiness` / `assertOwnAppointment` — viewing requires same-business; mutating (status changes, reschedule, cancel) additionally requires the appointment be assigned to *this* staff member. - **Own notes only**: `assertOwnNote` — any staff member may add a note; only its author may edit or delete it. - **Own schedule only**: `StaffService`'s break/time-off mutations check `existing.staffId === params.staffId` before writing (§8 — this exact check is what a real bug was found and fixed in). - **Owner-only actions stay out of scope**: nothing in this phase lets a barber change business hours, service pricing, other staff accounts, or their own service eligibility — those routes/actions simply don't exist yet (§9). - Prices/durations are always read from the database at write time; a staff-created booking goes through the identical `BookingService.createBooking` → `SchedulingService.createAppointment` transactional path a customer booking does. ## 7. Responsive design (desktop pass) The brief explicitly flagged that staff desktop needed more than the mobile layout simply centered. What was built: - **`StaffSidebarNav`** — a persistent left rail at `lg:` breakpoint, shared by `(staff)` and `(owner)` via a new `StaffShell` wrapper. `BottomNav`'s staff variant hides itself at `lg:` in turn (`lg:hidden`); the customer bottom nav is unaffected at every size — that's still correct Phase 4 behaviour. - **Calendar** — a genuine side-by-side split at `lg:`: the day/agenda view in the main column, a sticky "Right Now" panel (today's current appointment, reusing `selectCurrentAppointment`) in a fixed-width right column. Not built: a full click-to-preview split-pane for *arbitrary* selected appointments — that would mean inventing a new client-side selection/preview interaction the brief didn't require, versus the deliberately scoped "today's current appointment lives beside the calendar" version actually shipped. - **Clients list** — a 2-column card grid at `lg:` instead of a single divided list. - **Customer Details** — a 2-column layout at `lg:` (profile/contact/loyalty rail on the left, appointments tabs + notes on the right), rather than one long centered column. - **Left as single-column, deliberately**: Current Appointment, Appointment Details, and the Settings sub-pages (Working Hours/Breaks/Time Off/Profile/Notifications) — these are single-record detail/form views, the same pattern a desktop email client or issue tracker uses for a reading pane; widening them wouldn't add information, only whitespace. ## 8. Bugs found and fixed during this phase - **`StaffService.updateBreak`/`deleteBreak`/`updateTimeOff`/`deleteTimeOff` threw instead of failing gracefully.** All four called the throwing `assertOwnsResource` helper with no surrounding try/catch — a permission mismatch (staff A trying to edit staff B's break) would crash the Server Action instead of returning the `{success:false, validation}` shape every other `StaffService` function uses. Found while wiring the Breaks editor's Server Actions and noticing the calling code never wrapped these four in try/catch, unlike the established `assertOwnAppointment`/`assertOwnNote` pattern elsewhere. Fixed with inline non-throwing ownership checks and a new `PERMISSION_DENIED` error code; covered by two new integration tests per resource (`staff.service.integration.test.ts`) asserting a graceful failure, not a throw, and that the resource is left unchanged. - **Edit sheets showed the wrong values when switching targets.** `BreakFormSheet`/`TimeOffFormSheet` initialize their form state via `useState(existing?.field ?? default)`, but the parent list components rendered a single always-mounted sheet instance and only changed the `existing` prop when the user clicked a different item — React doesn't re-run `useState` initializers on a prop change without a remount. Caught by manual browser verification (editing "Lunch" showed default "Break"/12:00–13:00 instead of the real values) before any automated test would have caught it, since the integration/unit tests exercise the service layer, not this component-mount subtlety. Fixed with `key={existing?.id ?? "new"}` on both sheet instances, forcing a clean remount per target. - **A partial-day time off block was incorrectly rejected/accepted at a timezone-DST boundary in a new integration test.** The first version of a new "Block Time only blocks its own window" test constructed the appointment's `startAt` as a raw `${date}T12:00:00Z` UTC string; during British Summer Time that's 13:00 local London time, which landed exactly on the break's own end boundary and silently failed to trigger the expected conflict (a `<` vs `<=` edge, not a service bug — the fixture was wrong, not the code). Fixed by using the existing `wallClockToInstant` helper to construct the appointment from local business time instead of a raw UTC string, matching how the customer-booking integration suite already does this. - **A new seed fixture collided with an existing Phase 4 E2E fixture.** The Phase 5 seed's "Leo has someone checked in" appointment originally used Alex Chen — the same customer, barber, and service (`Haircut & Style` with Leo Vance) the Phase 4 "book again" E2E journey already uses as its own fixture, so after that journey ran, `findAppointmentCard(page, haircut, "Leo Vance")` matched two cards instead of one. Fixed by moving the seed fixture to a different customer (Sam Osei) — same test coverage value (still exercises `selectCurrentAppointment`'s CHECKED_IN tier), no collision. - **The new desktop "Right Now" panel duplicated a customer's name on the calendar page**, breaking a Playwright `getByText(...)` (strict-mode) assertion written before that panel existed. Not an app bug — the panel intentionally repeats the current appointment's summary beside the full day view — fixed by scoping the E2E assertion with `.first()`. ## 9. Testing - **Unit** (Vitest, no DB — 121 tests total, up from 96 before this phase): `selectCurrentAppointment` (all 5 priority tiers, including "ignores CANCELLED/NO_SHOW even inside their old time window"), `staff.service.test.ts` (all pure validators — working hours, breaks, time off, including the partial-day-block edge cases — plus `assertOwnsResource`), `features/staff/authorization.test.ts` (own-business, own-appointment, own-note, including the "matching staffId but wrong business" case). - **Integration** (Vitest, real throwaway Postgres — 55 tests total, up from 39 before this phase): `loyalty.service.integration.test.ts` (8 tests — idempotent auto-award, reward-created-once-not-stacked, disabled-programme rejection, staff/service eligibility rules, correction reason-required + balance-clamped) and `staff.service.integration.test.ts` (8 tests — working-hours round-trip, break/time-off conflict detection and the Save-Anyway bypass, partial-day-block-only-blocks-its-own-window, and the four permission-denied-not-thrown regression tests from §8). - **End-to-end** (Playwright, `e2e/staff-journeys.spec.ts`, 9 journeys, run serially against the real dev server and seeded database): sign in + see today's schedule; open current appointment; book + check in + start (chains into journey 4); complete + auto/manual loyalty stamp; find a customer + add a note; book a follow-up from a completed visit; change working hours (round-trips and restores the seeded default); add a recurring break (adds then deletes itself); add time off that conflicts with a real seeded appointment, resolve via Save Anyway, and verify the appointment itself was never cancelled. Journeys 8 and 9 additionally clean up via a direct `afterAll` DB delete as a safety net, since a break/time-off they create has no deterministic id for the seed to upsert against on a future run — without it, a test failing partway through would leave Marcus permanently marked "off today" and break journey 1 on the next run (this was found and fixed live while building the suite). Running them: ```bash npm run test # unit, no DB — 121 tests npm run test:integration # needs DATABASE_URL → a real Postgres — 55 tests npm run test:e2e # needs DATABASE_URL → a real (throwaway) Postgres — 15 journeys (6 Phase 4 + 9 Phase 5) ``` ## 10. Seed data added Extended `prisma/seed.ts` (still idempotent — safe to re-run against the same database): - Two more customers: Sam Osei (registered, sits exactly at the loyalty reward threshold) and Priya Kapoor (guest) — alongside the existing Alex Chen (registered) and Jordan Blake (guest). - A second recurring break (Leo, Tue/Thu) alongside Marcus's existing daily lunch. - Seven appointments spanning today across every status and all three staff members, anchored partly to fixed hours (COMPLETED, NO_SHOW, PENDING, CANCELLED, the later CONFIRMED "conflict fixture") and partly to the actual current time via a new `minutesFromNow()` helper (IN_PROGRESS, CHECKED_IN, and a not-checked-in-but-scheduled-now CONFIRMED row) — the latter three specifically so the seed reliably exercises all the top three tiers of `selectCurrentAppointment` across three different staff members regardless of when the seed happens to run. - Loyalty accounts, a stamp transaction ledger, and one AVAILABLE reward (Sam Osei, at threshold). - Two customer notes. **Known limitation, same as Phase 4's own fixed-hour fixtures:** the appointments anchored to fixed local hours (rather than `minutesFromNow`) assume the seed is run on the same calendar day it's being demonstrated/tested on — reasonable for a dev/demo seed re-run before each test suite invocation (as `e2e/global-setup.ts` does), not intended as a permanently-valid fixture set. ## 11. Assumptions made - **A barber manages only their own schedule this phase.** No all-staff calendar, no acting-on-behalf-of-another-barber — explicitly out of scope, matches the brief. - **Staff service eligibility is owner-only and unbuilt this phase** — a barber sees their assigned services read-only. - **Loyalty configuration is consume-only** — eligible-staff/eligible-services lists, stamps-required, reward type/value all come from whatever's already seeded; no owner-facing config screen was built. - **Notification preferences are UI-only** — no email/SMS/push delivery exists yet; this phase only persists the on/off grid. - **A single-business deployment**, same as every prior phase — `CustomerProfile` still has no `businessId` column, so customer-directory scoping goes through the appointment relation (§5). ## 12. What was manually verified - Signed in as each of the three seeded staff (owner-as-barber Marcus, Leo, Jamie) and walked the calendar, current appointment, clients list/detail, and every settings sub-page in a real browser against seeded data. - The `assertOwnsResource` fix and the stale-form-state fix (§8) were both re-verified live in the browser after the code change, not just assumed fixed from reading the diff. - The desktop sidebar, calendar+"Right Now" split, and both 2-column list/detail layouts at a 1280px viewport. - `npm run build`, `npx tsc --noEmit`, and `npx eslint .` all clean on the final state. - All three automated suites (121 unit / 55 integration / 15 E2E) passed together on one freshly created, migrated, and seeded database in a single run — the authoritative verification. (Re-running the full E2E suite repeatedly against the *same* long-lived database during development reliably reproduced stale-data collisions in both the pre-existing Phase 4 customer journeys and, before the §8 fix, the new Phase 5 ones — neither suite is designed to be re-run many times against one persistent database without a reseed; this is a pre-existing property of the Phase 4 suite, not something this phase changed, and is worth flagging for anyone setting up CI, per §13.) ## 13. Before Phase 6 - Owner administration (business settings, staff management, service/category management, booking-rule management, loyalty programme configuration) is entirely untouched — every owner-only permission check in this phase exists but has nothing behind it yet beyond the pre-existing `/owner` placeholder. - Real notification delivery (email/SMS/push) — this phase only built the on/off preference grid. - The E2E/integration suites still assume a caller-provided disposable Postgres with no CI wiring to spin one up automatically, same open item Phase 4 already flagged. - If a future phase adds an all-staff or owner calendar, `selectCurrentAppointment` and the calendar's day-grid rendering are already staff-id-parameterized and should extend cleanly rather than needing a rewrite. - Loyalty reward *redemption* (marking an AVAILABLE reward as REDEEMED) was explicitly out of scope this phase and is still unbuilt — `currentStamps` is allowed to sit at or above the threshold indefinitely once a reward is available.