Phase 5 is complete. All 53 tasks are done, verified on a fresh database in a single clean run: **121 unit tests**, **55 integration tests**, and **15 E2E journeys** (6 Phase 4 + 9 new Phase 5) all passing, plus a clean `tsc`/`eslint`/`build`. Summary of this final stretch: - **Fixed a real bug**: `StaffService`'s break/time-off update/delete functions threw uncaught instead of failing gracefully on a permission mismatch — fixed with proper `PERMISSION_DENIED` handling, covered by new integration tests. - **Built the remaining Phase 5 UI**: Breaks editor, Time Off editor, Barber Profile, and Notification Preferences — all wired to real services, no placeholders. - **Extended seed data**: more customers, loyalty accounts/transactions/rewards, and appointments spanning every status today (anchored to real time so the "Current Appointment" priority logic is reliably exercised). - **Found and fixed a second real bug live in the browser**: the Break/Time-Off edit sheets showed stale default values instead of the item being edited (a classic `useState`-doesn't-reinitialize-on-prop-change bug) — fixed with a `key` prop to force remount. - **Wrote the full Phase 5 test suite**: unit tests for `selectCurrentAppointment` and all `StaffService` validators, integration tests for `LoyaltyService` and `StaffService` (including the permission-fix regression tests), and 9 new Playwright E2E journeys covering the brief's exact required list. - **Did the desktop-responsive pass**: a persistent sidebar nav replacing the bottom bar at `lg:`, a genuine calendar+"Right Now" side-by-side split, and 2-column layouts for the clients list and customer details — addressing the brief's explicit "more than simple centering" requirement. - **Wrote [docs/phase-5-summary.md](docs/phase-5-summary.md)** documenting everything above in full. Nothing is committed — the whole session (Phases 2–5) sits uncommitted on top of the initial template commit, same as you left it. Let me know if you'd like me to commit, or if you want to move on to Phase 6.
134 lines
21 KiB
Markdown
134 lines
21 KiB
Markdown
# 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.
|