Files
NextChair/docs/phase-6-summary.md
Stefan Lazic 68a62e02a4
CI / ci (push) Canceled after 0s
Remaining features
Built
No-show flag and block:
Two new numbers under Booking Rules (blank means off).
A symbol appears on the clients list, client page, calendar entries and appointment pages.
Online booking is refused at the block number, and customers are told at the details step.
Staff can still book a blocked customer in, and an owner can reset someone with a two-tap button.
The count resets when they attend, and matches the same email or phone, so a guest can’t dodge it with a new profile.
Email rules: staff and owners get email only for customer cancellations, schedule changes and invitations. Routine events (new booking, reschedule) are push-only. Nobody is told about their own action. A barber reassignment sends one customer email instead of two. Reward expiry sends one warning.
Email redesign: your brand colour, a details card, Add to calendar and View on map links, a preferences link, hidden preview text, and dark mode. Replies now go to the business’s own email.
WhatsApp “due now” queue: a list in time order with a highlighted next one, an owner setting for how many hours ahead (12–72), and a once-a-day push telling each barber how many reminders are waiting.
Monitoring: /api/health now reports whether notifications are actually going out. The worker resets stuck deliveries and alerts the owner on the first setup failure (such as a bad SMTP login). The app emails your operator address about server errors, and the manager pushes that address to each business.
Google scope: now only calendar.app.created. Events go to a calendar the app creates in the staff member’s Google account, called “<Business> bookings”.
2026-10-17 23:10:18 +01:00

175 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 6 — Owner Administration: Summary
The complete owner administration experience, in the same application as the customer and barber experiences: dashboard, all-staff calendar, business details/hours, staff management and invitations, roles and service eligibility, service categories and services, booking rules, loyalty programme configuration and reward redemption, and business-wide settings. No separate owner app — owner is a permission on a staff account (the seeded owner, Marcus Reed, is also a working barber), reusing the existing auth/permission system, the `(staff)`-pattern shell, and the design system throughout. Four new focused domain services were added (`BusinessService`, `ServiceCatalogueService`, a small `booking-rule.service.ts`, and `OwnerReportingService`); everything else extends `StaffService`/`LoyaltyService` or is reused as-is.
---
## 1. What was built
- **Owner Dashboard** (`/owner`) — real, data-backed: today's appointment totals by status, staff working today, estimated occupancy (clearly labelled as an estimate, no invented revenue), popular services (last 30 days), recent loyalty activity, customers with an available reward, and configuration warnings (no opening hours, staff with no working hours, a service with no eligible active staff, an incomplete-but-enabled loyalty programme, time-off/appointment conflicts).
- **All-Staff Calendar** (`/owner/calendar`) — Day and Agenda views, staff filter pills, current-time indicator, reuses the exact same `DayCalendar`/`DateStrip`/`AppointmentCard` components the barber calendar uses (parametrized, not forked). Owner can view any appointment, create one (via the existing `/staff/book` flow — never duplicated), block time for any staff member, and reschedule/reassign.
- **Appointment Reassignment** — "Reschedule / Reassign" on the owner appointment details page reuses `BookingService.rescheduleBooking`'s existing (already staffId-aware) validation pipeline; no new domain logic was needed.
- **Owner Appointment Access** (`/owner/appointments/[id]`) — view/update status, reschedule/reassign, apply a loyalty reward, book on behalf of a customer, view customer notes — via owner-specific authorization helpers, kept separate from (not weakening) barber authorization.
- **Business Details** (`/owner/settings/business`) — name/logo/description/address/contact/social links/timezone/currency/locale/policies, backed by `Business`/`BusinessLocation`/`BusinessSettings`.
- **Opening Hours** (`/owner/settings/hours`) — per-weekday enable/times, "copy Monday to all days", conflict review before saving (shows affected future appointments, never auto-cancels, requires explicit "Save Anyway").
- **Staff Management** (`/owner/team`, `/owner/team/[id]`) — search/add/view/deactivate/reactivate, role assignment, service eligibility, working-hours edit-on-behalf-of. Deactivation is conflict-reviewed and last-owner-protected.
- **Add Staff / Invitations** (`/owner/team/add`, `/invite/accept`) — creates a fully usable staff profile immediately, plus a `StaffInvitation` row surfaced as a dev-safe copy-link (no real email delivery this phase).
- **Roles and Permissions** — fixed built-in Owner/Barber roles (schema supports more); last-active-owner protected against self-removal/self-deactivation.
- **Staff Service Eligibility** — per-staff checklist of active services, conflict-reviewed when removing a service with future bookings.
- **Service Categories and Services** (`/owner/services`) — create/rename/reorder (up/down buttons, not drag-and-drop)/activate-deactivate/delete-if-unused, assign eligible staff, configure online-booking/buffers. Money handled as strings end-to-end, never a JS `number`.
- **Booking Rules** (`/owner/settings/booking-rules`) — minimum notice, max advance window, cancellation/reschedule windows, slot interval, buffers, per-day and per-staff-per-day caps, guest booking, barber selection, approval requirement.
- **Loyalty Configuration** (`/owner/settings/loyalty`) — enable/disable, stamps required, reward type (percentage/fixed/free-service) and value, eligible services/staff, expiry, auto-apply, card appearance, terms.
- **Loyalty Reward Redemption** (new this phase — Phase 5 deliberately left it unbuilt) — apply an AVAILABLE reward to an eligible appointment, atomically marking it REDEEMED, deducting stamps, and recording a discount on the appointment; available to both Owner and Barber (it's an operational action, not owner-only configuration).
- **Business Settings** — currency/locale/timezone/policies via the existing structured `BusinessSettings` model (no unstructured JSON blob).
- **Owner Settings Hub** (`/owner/settings`) — links to all the above, plus "Switch to Barber Tools" and the existing personal notification-preferences page (reused, not duplicated).
- **Audit Logging** — staff added/deactivated/role-changed/services-changed, business profile/hours changed, booking rules changed, loyalty config changed, reward redeemed, appointment reassigned — all written to the pre-existing (previously-unused) `AuditLog` table.
## 2. Routes created
Inside `(owner)` (persistent nav via the shared `StaffShell`, parametrized with `OWNER_NAV_ITEMS`):
`/owner`, `/owner/calendar`, `/owner/appointments/[id]`, `/owner/team`, `/owner/team/add`, `/owner/team/[id]`, `/owner/team/[id]/hours`, `/owner/services`, `/owner/settings`, `/owner/settings/business`, `/owner/settings/hours`, `/owner/settings/booking-rules`, `/owner/settings/loyalty`.
Outside `(owner)` (own fixed-bottom-bar UI, same route-group-splitting pattern Phase 4/5 established for `/book` and `/staff/appointments/[id]/reschedule`):
`/owner/appointments/[id]/reschedule`.
Public (inside `(auth)` for its shared centered-card shell, not staff/owner-gated):
`/invite/accept`.
## 3. Components
**New, owner-scoped** (`src/features/owner/components/`): `owner-block-time-button.tsx`, `redeem-reward-button.tsx`, `business-details-form.tsx`, `opening-hours-form.tsx`, `add-staff-form.tsx`, `staff-detail-panel.tsx`, `catalogue-manager.tsx`, `service-form-sheet.tsx`, `booking-rules-form.tsx`, `loyalty-config-form.tsx`.
**New, shared auth** (`src/features/auth/components/accept-invitation-form.tsx`).
**Generalized, not forked** (the same dependency-injection pattern Phase 5 used for `ServiceSelector.onContinue`):
- `AppointmentStatusActions` gained an optional `actions?: AppointmentActionSet` prop (default = staff Server Actions); the owner appointment page injects the owner variant instead of forking the component.
- `WorkingHoursForm` gained an optional `onSave` prop so `/owner/team/[id]/hours` injects `updateStaffWorkingHoursAction` instead of the self-service action.
- `DayCalendar` gained an optional `appointmentHrefBase` prop so the same component links to `/owner/appointments/[id]` or `/staff/appointments/[id]` depending on context.
- `DateStrip` gained an optional `extraParams` prop so the owner calendar's `?staffId=` filter survives date navigation.
- `StaffShell`/`StaffSidebarNav`/`BottomNav` all gained an `items` prop (default `STAFF_NAV_ITEMS`); the owner layout passes `OWNER_NAV_ITEMS` instead. `StaffSidebarNav` additionally gained an optional `footerLink` prop for the owner's "Barber Tools" back-link — explicitly one shared nav component, not competing owner/staff variants.
## 4. Domain services
**New:**
- **`BusinessService`** (`src/services/business/business.service.ts`) — business profile/location/settings CRUD, opening-hours validation and conflict-checked read/write. Deliberately excludes booking rules (scheduling policy) and loyalty (its own service) — see its own file header.
- **`ServiceCatalogueService`** (`src/services/catalogue/service-catalogue.service.ts`) — categories and services CRUD, reordering, usage-conflict lookup. Staff eligibility stays in `StaffService` (it's staff-scoped data, not catalogue data).
- **`booking-rule.service.ts`** (`src/services/scheduling/booking-rule.service.ts`) — read/write and validation for the `BookingRule` row itself. A new sibling file rather than an addition to `business-rules.service.ts`, because that file is deliberately pure/Prisma-free (its own header says so) and needs to stay that way to remain cheap to unit test; this file owns the one thing that one doesn't — loading and persisting the configuration an owner actually edits.
- **`OwnerReportingService`** (`src/services/reporting/owner-reporting.service.ts`) — read-only dashboard aggregation (`getDashboardSummary`, `getConfigurationWarnings`). Exists only because the dashboard's queries are genuinely more than "call one existing service and render it"; not a general owner data-access layer, and no other feature imports from it.
- **`StaffInvitationService`** (`src/services/staff/staff-invitation.service.ts`) — invitation token generation/validation/acceptance. Kept out of `StaffService` deliberately (acceptance tracking is a distinct concern from the staff profile itself, which is fully usable the moment it's created).
**Extended:**
- **`StaffService`** — owner-facing additions: `listStaffForBusiness`, `getStaffDetailById`, `listRolesForBusiness`, `createStaffProfile`, `updateStaffAsOwner`, `deactivateStaff`/`reactivateStaff` (both last-owner-protected), `setStaffServiceEligibility`, all with matching conflict-finder functions (`findStaffDeactivationConflicts`, `findServiceEligibilityConflicts`).
- **`LoyaltyService`** — programme configuration (`getProgrammeConfig`, `updateProgrammeConfig`, `setEligibleServices`, `setEligibleStaff`) and reward redemption (`listEligibleAppointmentsForReward`, `redeemReward`).
**Reused as-is:** `BookingService.rescheduleBooking` (for reassignment), `AppointmentService`, `AvailabilityService`, `StaffAvailabilityService`, `BusinessRulesService` (evaluation logic only), `CustomerService`.
**New (feature-layer, not domain services):** `src/features/owner/authorization.ts` (`assertOwnerPermission`, mirroring `src/features/staff/authorization.ts`), `src/lib/audit/write-audit-log.ts`.
## 5. Schema changes
Two migrations:
**`20260803070350_owner_administration`:**
- `Permission` enum gained `MANAGE_ALL_APPOINTMENTS` and `VIEW_REPORTING`.
- `Appointment.discountAmount Decimal? @db.Decimal(10,2)` — the effect of a redeemed loyalty reward, kept separate from `priceAtBooking`/`durationAtBookingMinutes` snapshots so those never need to change.
- New `StaffInvitation` model (token, email, expiresAt, acceptedAt, invitedByUserId) — one per `StaffProfile`, opaque DB-validated token (not HMAC-signed like the booking-draft cookie, since it's a single-use server-validated lookup, not something a client needs to verify offline).
- `LoyaltyReward` gained a real FK for `appliedToAppointment` (was already a plain string column) plus `redeemedByStaffId`/`redeemedByStaff`.
**`20260803071240_loyalty_free_service`:**
- `LoyaltyProgramme.freeServiceId`/`freeService` — which service is free for a `FREE_SERVICE` reward. A genuine schema gap: the existing `eligibleServices` list serves a different purpose (which services *earn* stamps), not "which service the reward covers."
Both applied via `npx prisma migrate dev` against a real Postgres, then `npx prisma generate`; seed and tests updated to match.
**Reviewed and deliberately not added:** a `Business.isPubliclyVisible` toggle (no consumer/enforcement point yet in a single-business app), a dedicated "business exceptional closures" table (the brief said "where appropriate"; opening-hours conflict review already covers the common case).
## 6. Dashboard metric approach
`getDashboardSummary` composes, for a given date: `listAppointments` (existing service) filtered/counted by status for the totals row; `WorkingHours`/`TimeOff` for "staff working today" vs "staff on time off"; occupancy as `bookedMinutes / availableMinutes` across staff actually working that day, rounded to a percent and always rendered with the label "an estimate, not a financial figure" — never a revenue figure, since the brief explicitly warned against inventing one. Popular services aggregates `AppointmentService` rows for `COMPLETED` appointments in the last 30 days, in memory (not a SQL `GROUP BY`) since the dataset size doesn't warrant it. Configuration warnings (`getConfigurationWarnings`) are a separate, independently-callable function: no opening hours at all, a staff member with no working day set, an active service with no active eligible staff, an enabled-but-incomplete loyalty programme (reusing `validateProgrammeConfig` rather than re-implementing the check), and upcoming time-off that overlaps an existing appointment.
## 7. All-staff calendar approach
The owner calendar page is new, but it renders zero new calendar UI: it reuses `DayCalendar` (one column per staff member, or a single column when filtered) and `AppointmentCard` (agenda view) exactly as the barber calendar does, just fetching `listAppointments` for the whole business instead of one staff member and looping columns. The staff filter and Day/Agenda toggle are plain `<Link>`s carrying query params (`?date=&view=&staffId=`), so the page stays a Server Component with no client-side calendar state to keep in sync. "New" links straight to `/staff/book` (already lets any signed-in staff member, owner included, pick any eligible barber) rather than a duplicate `/owner/book` flow.
## 8. Business/opening-hours behaviour
`setOpeningHours` validates structurally first (open day needs both times, times within a single day, open before close), then — unless the caller passes `acknowledgeConflicts` — calls `findOpeningHoursConflicts`, which finds future, non-cancelled appointments that would fall outside the *proposed* hours (either on a day newly marked closed, or outside a still-open day's new window). If any exist, the save is rejected with the conflict list attached; the UI shows each affected appointment and offers "Go Back" or "Save Anyway." Saving never touches the appointments themselves — only `BusinessHours` rows, in one transaction, in an upsert per weekday. The customer-facing availability engine reads `BusinessHours` fresh on every request, so a save is reflected immediately with no cache to invalidate.
## 9. Staff invitation/management approach
`createStaffProfile` creates a fully usable `User` + `StaffProfile` + initial `StaffService` eligibility rows in one nested `create` — the profile can appear on the calendar and be booked immediately, it is never gated on invitation acceptance. `StaffInvitationService.createInvitation` separately creates a `StaffInvitation` row with a random 32-byte base64url token and a 7-day expiry; the owner UI surfaces it as a copy-link (`/invite/accept?token=...`) since there's no real email delivery this phase — documented as a deliberate dev-safe shortcut, not a production gap to hide. Accepting sets a password (bcrypt-hashed, mirroring `registerAction`), marks `emailVerified`/`acceptedAt`, and signs the new staff member in immediately. Deactivation (`deactivateStaff`) is conflict-reviewed exactly like opening hours (future appointments shown, "Deactivate Anyway" required) and last-owner-protected (see §14); it flips `StaffProfile.status` to `INACTIVE` and never deletes the row, preserving appointment history integrity (`Appointment.staff` is `onDelete: Restrict` for exactly this reason).
## 10. Service/category management approach
Categories and services share one conflict-review-free CRUD path for create/edit/reorder/activate-deactivate (these can never orphan an existing booking — a deactivated service or category simply stops appearing for *new* bookings), but deletion is different: `deleteCategory` refuses outright if the category still has any services (active or not), directing the owner to deactivate instead — deletion is only for genuinely empty, unused categories. Editing a service's price or duration only ever changes the live `Service` row; the historical `AppointmentService.priceAtBooking`/`durationAtBookingMinutes` snapshot on every past and already-confirmed appointment is never rewritten (verified by a dedicated integration test). Reordering is up/down buttons per item, not drag-and-drop, matching the brief's own accessibility note about keyboard alternatives.
## 11. Booking-rule behaviour
`updateBookingRule` validates every field's range (minimum notice ≥ 0, max advance ≥ 1 day, slot interval 5–120 minutes, per-day/per-staff-per-day caps ≥ 1 or unlimited) and upserts the single `BookingRule` row for the business. No conflict-review step exists for booking rules specifically — tightening a rule (e.g. requiring approval, shortening the cancellation window) changes how *future* bookings behave without altering any existing appointment's stored state, so there is nothing to show an affected-appointments list for; the customer booking flow reads the current `BookingRule` row fresh on every booking attempt, so a save takes effect on the very next booking (exercised directly in the E2E "require approval" journey, §16).
## 12. Loyalty configuration/redemption approach
`updateProgrammeConfig` validates only what's required *when enabled* (`validateProgrammeConfig`) — a disabled, half-configured draft is allowed since the owner may still be working on it; enabling a `FREE_SERVICE` reward additionally checks the chosen service is active. `setEligibleServices`/`setEligibleStaff` replace their respective lists wholesale in a transaction (delete-all-then-recreate), matching the same pattern `setStaffServiceEligibility` uses.
`redeemReward` is the one place `LoyaltyReward.status`, `LoyaltyAccount.currentStamps`, and `Appointment.discountAmount` ever change together, inside a single `$transaction`. The status flip uses a conditional `updateMany` (`WHERE status = 'AVAILABLE'`) rather than a read-then-write, so two simultaneous redemption attempts can't both succeed — the loser sees `REWARD_NOT_AVAILABLE`, never a double deduction (covered by a dedicated integration test that redeems, then attempts a second redemption of the same reward). The discount is computed from the reward type (percentage of subtotal, fixed amount, or the price of the matching free-service line), clamped to `[0, subtotal]`, and written to `Appointment.discountAmount` — the original `priceAtBooking` snapshots are never touched, so the subtotal is always recoverable and the final total is always `subtotal - discountAmount`, never negative. A documented simplification: redemption reads the loyalty programme's *current* `stampsRequired`/`freeServiceId`, not a value snapshotted when the reward was earned — if the owner reconfigures the programme in between, redemption reflects the new configuration.
## 13. Conflict-review behaviour
One pattern, reused everywhere a change could strand an existing appointment: **validate → find affected future appointments → if any and not yet acknowledged, return them without writing anything → the UI shows each one and offers "Go Back" or an explicit "\[Verb] Anyway" → only that second call actually writes, and only ever the configuration being changed, never the appointments themselves → the same call also writes an audit log entry.** This exact shape now covers business opening hours (§8), staff deactivation (§9), and staff service-eligibility removal (§10) — all three share the "conflicts: []" / "acknowledgeConflicts" parameter shape and all three are exercised by an integration test asserting the blocked-then-Save-Anyway-then-appointment-still-intact sequence. Booking rules (§11) and loyalty configuration (§12) don't need this pattern, for the reasons given in their own sections.
## 14. Permission and business-isolation rules
The `(owner)` layout gates on `MANAGE_STAFF` as a coarse "is this person any kind of owner" shell check — but every individual Server Action re-derives the session and checks the *specific* permission that action needs (`MANAGE_BUSINESS_PROFILE`, `MANAGE_SERVICES`, `MANAGE_BOOKING_RULES`, `MANAGE_LOYALTY_PROGRAMME`, `MANAGE_ALL_APPOINTMENTS`, `VIEW_ALL_STAFF_APPOINTMENTS`, `VIEW_REPORTING`), via `assertOwnerPermission` → `hasPermission`, which re-queries the database every time rather than trusting the session/JWT — a permission change (or a demotion) takes effect on the very next request, not after the token expires (verified directly: see the integration test asserting a permission change is reflected immediately, §16). Every owner service function additionally re-scopes its query by `businessId` even though this is a single-business v1 app — explicit defense-in-depth, not dead code, matching the same discipline Phase 4/5 applied. **Last-owner protection**: "an owner" is defined as any staff member whose role holds `MANAGE_STAFF` (not a hardcoded role name, so a future custom role with that permission is automatically covered); both `deactivateStaff` and `updateStaffAsOwner` (role change) refuse the action if it would leave zero other active staff members holding `MANAGE_STAFF`.
## 15. Audit logging
`writeAuditLog(prisma, {actorUserId, action, entityType, entityId, metadata})` is called from every owner mutation Server Action (not from inside the domain services themselves, keeping the domain layer free of "who did this and why" concerns) with an action-specific name: `BUSINESS_PROFILE_UPDATED`, `STAFF_ADDED`, `STAFF_ROLE_CHANGED`/`STAFF_UPDATED`, `STAFF_DEACTIVATED`/`STAFF_REACTIVATED`, `STAFF_SERVICES_CHANGED`, `STAFF_WORKING_HOURS_CHANGED`, `BOOKING_RULES_UPDATED`, `LOYALTY_CONFIG_UPDATED`, `LOYALTY_REWARD_REDEEMED`, `APPOINTMENT_REASSIGNED` (only written when the staff actually changes, not on a plain reschedule). `metadata` is typed `Prisma.InputJsonValue` and only ever carries safe, already-public-to-the-owner values (names, ids, booleans) — no passwords, invitation tokens, or other secrets are ever passed in.
## 16. Tests
**Unit** (Vitest, no DB — 179 tests total, up from ~150 before this phase): pure validators for every new input shape — `validateBusinessProfileInput`/`validateOpeningHoursInput` (business.service.test.ts), `validateCategoryInput`/`validateServiceInput` including the decimal-price regex (service-catalogue.service.test.ts), `validateBookingRuleInput` including every range boundary (booking-rule.service.test.ts), `validateProgrammeConfig` for all three reward types and the enabled-vs-disabled leniency (loyalty-config.test.ts), and `validateCreateStaffInput` (appended to the existing `staff.service.test.ts`).
**Integration** (Vitest, real throwaway Postgres — 86 tests total): `business.service.integration.test.ts` (opening-hours conflict detection + Save Anyway + the appointment stays untouched), `service-catalogue.service.integration.test.ts` (category-deletion-blocked-when-has-services, business isolation, and the historical-snapshot-preservation test described in §10), `staff-owner.integration.test.ts` (staff creation + duplicate-email rejection, last-owner protection on both deactivation and role change, deactivation/eligibility conflict review + Save Anyway), `loyalty-owner.integration.test.ts` (programme config create-then-update, free-service-must-be-active, eligible-services replace-wholesale, and the redemption suite: successful redemption's atomic discount/status/stamp effects, duplicate-redemption race prevention via the conditional `updateMany`, already-discounted-appointment rejection, wrong-customer rejection), and `authorization.integration.test.ts` (owner-vs-barber permission grants/denials against real `Role`/`RolePermission` rows, a live permission-change-takes-effect-immediately test, and business-isolation checks on `updateStaffAsOwner`/`deactivateStaff`/`deleteCategory` against a second business's data).
**End-to-end** (Playwright, `e2e/owner-journeys.spec.ts`, 12 journeys, run serially): dashboard with real data and configuration warnings; all-staff calendar filter + Day/Agenda; business details edit + persistence; opening hours tightened + conflict shown + Save Anyway + appointment untouched; add a barber and get a dev invite link; change a barber's role and service eligibility (round-tripped); create a category and service, verified immediately on the customer `/book` page; require booking approval and verify a fresh customer booking lands `Pending Approval`; configure the loyalty programme; redeem a customer's available reward; a barber without `MANAGE_STAFF` is redirected away from `/owner`; the owner switches back to Barber Tools without losing the barber nav. Every journey that mutates shared configuration (business details, opening hours, booking rules, loyalty config) restores the original value at the end of the same test, matching the Phase 5 precedent; journey 10 (redeem reward) is a documented one-shot consumption of the seed's single AVAILABLE reward, same category as Phase 5's own booking journeys.
Running them:
```bash
npm run test # unit, no DB — 179 tests
npm run test:integration # needs DATABASE_URL → a real Postgres — 86 tests
npm run test:e2e # needs DATABASE_URL → a real (throwaway) Postgres — 27 journeys (6 Phase 4 + 9 Phase 5 + 12 Phase 6)
```
## 17. Manual verification
- Signed in as the owner (Marcus Reed) and walked every new page — dashboard, all-staff calendar (day + agenda, staff filter), business details, opening hours, team list + staff detail (role/eligibility/deactivate), add-staff + invite-link, services & categories (add/edit/reorder/delete-blocked), booking rules, loyalty config, appointment details + redeem reward, reschedule/reassign — in a real browser against seeded data, at both mobile (375px) and desktop (1280px) viewports.
- Signed in as a barber (Leo Vance, no `MANAGE_STAFF`) and confirmed `/owner` redirects to `/staff` with no owner nav visible.
- Verified the desktop two-column treatment on Business Details, Booking Rules, and Loyalty Configuration (element rects confirmed genuinely side-by-side, not just visually similar in a screenshot), the Team page's 2-column card grid, the Staff Detail page's master-detail split, and the all-staff calendar's multi-column day view — and confirmed all of them still stack to a single column at 375px with no regression.
- `npx tsc --noEmit`, `npx eslint .`, and `npx next build` all clean on the final state.
- All three automated suites (179 unit / 86 integration / 27 E2E) passed together on one freshly created, migrated, and seeded database in a single run — the authoritative verification (see §19 for why a *repeated* run against the same database needs care).
## 18. Assumptions made
- **A single-business deployment**, same as every prior phase — no multi-business owner switching was built or implied.
- **"An owner" is a permission, not an account type** — Owner/Barber are the only two roles seeded, but every last-owner check is written against "does this role hold `MANAGE_STAFF`," so a future `Manager`/`Receptionist` role with a subset of owner permissions fits without a schema or logic change.
- **Redemption eligibility is service-agnostic beyond `FREE_SERVICE`** — a percentage or fixed-amount reward can be applied to any of the customer's own appointments, not restricted to appointments containing "eligible" services (eligibility governs *earning* stamps, not which appointment a reward can be *applied to*).
- **No payments/deposits are modelled** — a redeemed reward's discount is recorded, but there is no payment capture, refund, or deposit-hold anywhere in this phase, matching the explicit scope boundary.
- **The dev invite-link flow is accepted as a permanent, documented dev-mode behaviour for this phase**, not a stopgap hidden from the summary — real delivery is explicitly out of scope (§20).
## 19. Known limitations
- **Working-hours editing had no conflict detection** (both the self-service and owner-on-behalf editors) — **closed in Phase 7** ("Working-hours conflict review": `findWorkingHoursConflicts` / `setWorkingHours({ acknowledgeConflicts })` in `staff.service.ts`, the conflict list in `working-hours-form.tsx`, covered by `staff.service.integration.test.ts`). Kept here only as history.
- **~~Owner-editable breaks/whole-day time off for a staff member were not built~~** — *resolved.* Owner-on-behalf-of editing of a staff member's breaks and whole-day time off was built in Phase 7 (Manage → Team → staff → Breaks / Time Off). The one remaining piece, editing and deleting partial-day "Block Time" entries (which `OwnerBlockTimeButton` creates), now also works: they are listed on the same Time Off page, for the owner (`MANAGE_STAFF`) and for staff on their own Time Off settings page, using the same update/delete actions and conflict review.
- **Staff service eligibility is edited only from the Staff Detail page**, not duplicated on the Services & Categories page (the brief mentions "assign eligible staff" under Services too) — one edit surface, avoiding two places that could drift out of sync.
- **"Requires staff approval to redeem" and "Apply reward automatically" were removed from the loyalty settings form** — neither did anything (redemption is always staff-initiated and gated by `MANAGE_LOYALTY_STAMPS`). The two `LoyaltyProgramme` columns still exist, unused.
- **A repeated run of the E2E suite against the same (non-reseeded) database is not fully idempotent**: journey 10 (redeem reward) consumes the seed's one AVAILABLE reward, and one seed appointment (`appt-today-checkedin`, timed via `minutesFromNow` to reliably exercise `selectCurrentAppointment`'s CHECKED_IN tier) can occasionally start a few minutes before the business's 10:00 opening depending on exactly when the seed happens to run, which the opening-hours conflict test now handles defensively rather than assuming a clean save — this is a real, if narrow, characteristic of time-relative seed fixtures re-run without a fresh database, same category of issue Phase 5's own summary flagged (§13 there); it does **not** affect a single authoritative run against a freshly seeded database, which is how CI should always run it.
- **Notification delivery, payments, deposits, advanced financial reporting, payroll, multi-business/multi-location, PWA, and offline** remain entirely out of scope, per the brief's own boundary.
## 20. Review items for Phase 7
- ~~Decide whether owner-editable breaks and whole-day time off (on behalf of staff) should be built~~ — built in Phase 7; Block Time edit/delete followed later (see §19).
- Decide whether working-hours edits (both self-service and owner-on-behalf-of) should gain the same conflict-review treatment as breaks/time-off/business-hours, closing the gap flagged in §19.
- Real notification delivery (email/SMS/push) is still entirely unbuilt — both the Phase 5 preference grid and this phase's invite-link workaround are waiting on it.
- Payments/deposits and any real financial reporting beyond the operational dashboard estimate are unstarted; `Appointment.discountAmount` exists and is populated correctly, but nothing yet reconciles it against an actual payment.
- If a future phase adds custom (non-Owner/Barber) roles with a role editor, the "is an owner" check (role holds `MANAGE_STAFF`) and the fixed built-in-roles assumption in `listRolesForBusiness`/`AddStaffForm` should be revisited together.
- The E2E suite's reliance on a freshly seeded database for a fully clean run (§19) has no CI wiring yet to provision one automatically — same open item Phase 4/5 already flagged, still unresolved.