diff --git a/docs/phase-6-summary.md b/docs/phase-6-summary.md
new file mode 100644
index 0000000..8320019
--- /dev/null
+++ b/docs/phase-6-summary.md
@@ -0,0 +1,174 @@
+# 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 ``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 has no conflict detection**, on both the self-service (Phase 5) and owner-on-behalf-of-staff (this phase) editors — unlike breaks, time off, business hours, staff deactivation, and service-eligibility removal, all of which do. A pre-existing Phase 5 gap this phase chose not to retroactively fix, given the size of the remaining Phase 6 scope; flagged again here rather than silently carried forward.
+- **Owner-editable breaks/whole-day time off for a staff member were not built** — only partial-day "Block Time" (via `OwnerBlockTimeButton`) satisfies "add time off on behalf of staff" this phase; a staff member's own recurring breaks and whole-day time off can currently only be edited by that staff member themselves.
+- **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" is informational text only** in the loyalty config UI — redemption is already gated behind the `MANAGE_LOYALTY_STAMPS` permission and there is no customer-initiated redemption request to approve against, so no separate approval-queue workflow exists.
+- **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, or whether "Block Time" (partial-day) remains the only owner-side lever indefinitely.
+- 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.
diff --git a/e2e/owner-journeys.spec.ts b/e2e/owner-journeys.spec.ts
new file mode 100644
index 0000000..4c2708a
--- /dev/null
+++ b/e2e/owner-journeys.spec.ts
@@ -0,0 +1,331 @@
+import { expect, test } from "@playwright/test";
+import { Client } from "pg";
+import { SEEDED, completeBookingFlow, signIn, uniqueEmail } from "./helpers";
+
+/**
+ * The 12 owner-administration journeys required by the Phase 6 brief. Same
+ * principles as staff-journeys.spec.ts: real forms against the real dev
+ * server and seeded Postgres, `test.describe.serial` because several
+ * journeys deliberately chain or share seeded fixtures (Marcus Reed is
+ * both the seeded owner *and* a working barber — see prisma/seed.ts), and
+ * every mutation a test makes to shared configuration (business details,
+ * opening hours, booking rules, loyalty config) is restored at the end of
+ * that same test so later journeys — and later re-runs — see the same
+ * starting state. The one exception is journey 10 (redeem reward), which
+ * consumes the seed's one-time AVAILABLE reward for Sam Osei; that's a
+ * deliberate one-shot fixture, same category as staff-journeys.spec.ts's
+ * own booking journeys.
+ */
+
+test.describe.serial("owner administration journeys", () => {
+ test.afterAll(async () => {
+ const client = new Client({ connectionString: process.env.DATABASE_URL });
+ await client.connect();
+ // Journey 7 creates a category/service with no deterministic id to
+ // upsert against on a re-run — clean up by name instead.
+ await client.query(`DELETE FROM "Service" WHERE name LIKE 'E2E Test Service%'`);
+ await client.query(`DELETE FROM "ServiceCategory" WHERE name LIKE 'E2E Test Category%'`);
+ // Journey 5 adds a real staff member (User+StaffProfile+StaffInvitation) with no
+ // deterministic id either — deleting the User cascades to the rest.
+ await client.query(`DELETE FROM "User" WHERE email LIKE 'e2e-new-barber-%@e2e-test.local'`);
+ await client.end();
+ });
+
+ test("1. owner sees a real, data-backed dashboard with configuration warnings", async ({ page }) => {
+ await signIn(page, SEEDED.owner.email, "password123");
+ await page.goto("/owner");
+
+ await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
+ // Seeded fixtures from prisma/seed.ts: Owen Clarke has no working hours,
+ // and "Kids Haircut (Under 12)" has no eligible active staff member.
+ await expect(page.getByText("Owen Clarke has no working hours set")).toBeVisible();
+ await expect(page.getByText(/no active staff member is eligible to perform it/)).toBeVisible();
+ await expect(page.getByText("Staff Today")).toBeVisible();
+ // Scoped exact match — "Marcus Reed" can otherwise also match a Recent Loyalty
+ // Activity line naming him as the staff member who redeemed a reward.
+ await expect(page.getByText("Marcus Reed", { exact: true })).toBeVisible();
+ await expect(page.getByText("Popular Services")).toBeVisible();
+ });
+
+ test("2. owner views the all-staff calendar, filters by staff, and switches to agenda", async ({ page }) => {
+ await signIn(page, SEEDED.owner.email, "password123");
+ await page.goto("/owner/calendar");
+
+ await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
+ await expect(page.getByRole("link", { name: "Marcus Reed", exact: true })).toBeVisible();
+ await expect(page.getByRole("link", { name: "Leo Vance", exact: true })).toBeVisible();
+
+ // Filtering to one staff member narrows the day view to a single column.
+ await page.getByRole("link", { name: "Leo Vance", exact: true }).click();
+ await expect(page).toHaveURL(/staffId=/);
+ // The single remaining day-view column is headed by a
with the staff member's exact name
+ // (the filter pill link has the same text, so scope to the paragraph specifically).
+ await expect(page.locator("p").getByText("Leo Vance", { exact: true })).toBeVisible();
+
+ await page.getByRole("link", { name: "Agenda", exact: true }).click();
+ await expect(page).toHaveURL(/view=agenda/);
+ await expect(page.getByRole("link", { name: "Day", exact: true })).toBeVisible();
+
+ await page.getByRole("link", { name: "All Staff", exact: true }).click();
+ await expect(page).not.toHaveURL(/staffId=/);
+ });
+
+ test("3. owner edits business details and the change persists", async ({ page }) => {
+ await signIn(page, SEEDED.owner.email, "password123");
+ await page.goto("/owner/settings/business");
+
+ await expect(page.getByRole("heading", { name: "Business Details" })).toBeVisible();
+ const nameInput = page.getByLabel("Business Name");
+ await expect(nameInput).toHaveValue("Blake Heritage Barbershop");
+
+ await nameInput.fill("Blake Heritage Barbershop (E2E edited)");
+ await page.getByRole("button", { name: "Save", exact: true }).click();
+ await expect(page.getByText("Saved — changes are live for customers immediately.")).toBeVisible();
+
+ await page.reload();
+ await expect(page.getByLabel("Business Name")).toHaveValue("Blake Heritage Barbershop (E2E edited)");
+
+ // Restore the seeded name so later runs (and other journeys) see the original.
+ await page.getByLabel("Business Name").fill("Blake Heritage Barbershop");
+ await page.getByRole("button", { name: "Save", exact: true }).click();
+ await expect(page.getByText("Saved — changes are live for customers immediately.")).toBeVisible();
+ });
+
+ test("4. owner tightens opening hours, reviews the appointment conflict, and saves anyway without cancelling it", async ({ page }) => {
+ await signIn(page, SEEDED.owner.email, "password123");
+
+ // Marcus and Leo each have a seeded future CONFIRMED appointment (see
+ // prisma/seed.ts, appt-upcoming-confirmed / appt-leo-future). Those rows
+ // are upserted by a deterministic id, so on a database that already had
+ // them from an earlier seed run their date is whatever it was back
+ // then, not necessarily still "N days from today" — read the real date
+ // straight from Postgres instead of predicting it from wall-clock time.
+ const client = new Client({ connectionString: process.env.DATABASE_URL });
+ await client.connect();
+ const { rows } = await client.query<{ id: string; start_at: Date; first_name: string; last_name: string }>(
+ `SELECT a.id, a."startAt" as start_at, c."firstName" as first_name, c."lastName" as last_name
+ FROM "Appointment" a JOIN "CustomerProfile" c ON c.id = a."customerId"
+ WHERE a.id LIKE '%-appt-upcoming-confirmed' OR a.id LIKE '%-appt-leo-future'
+ ORDER BY a."startAt" ASC`,
+ );
+ await client.end();
+ if (rows.length === 0) throw new Error("Seeded opening-hours conflict fixtures not found — check prisma/seed.ts");
+
+ // Prefer a fixture that doesn't fall on Sunday (already closed by default) so toggling it
+ // off is a genuine change; fall back to whichever fixture exists if only one does.
+ const candidate =
+ rows.find((r) => r.start_at.toLocaleDateString("en-GB", { weekday: "long" }) !== "Sunday") ?? rows[0]!;
+ const targetDay = candidate.start_at.toLocaleDateString("en-GB", { weekday: "long" });
+ const expectedCustomer = `${candidate.first_name} ${candidate.last_name}`;
+
+ await page.goto("/owner/settings/hours");
+ await expect(page.getByRole("heading", { name: "Opening Hours" })).toBeVisible();
+
+ await page.getByRole("switch", { name: `${targetDay} open` }).click();
+ await page.getByRole("button", { name: "Save", exact: true }).click();
+
+ await expect(page.getByText(/would leave \d+ upcoming appointment/)).toBeVisible();
+ await expect(page.getByText(expectedCustomer)).toBeVisible();
+
+ await page.getByRole("button", { name: "Save Anyway", exact: true }).click();
+ await expect(page.getByText("Saved. The booking engine reflects these hours immediately.")).toBeVisible();
+
+ // The conflicting appointment itself is untouched — never silently cancelled.
+ await page.reload();
+ await expect(page.getByRole("switch", { name: `${targetDay} open` })).toHaveAttribute("data-state", "unchecked");
+
+ // Restore the seeded default (open 10:00-19:00) so later runs see the same starting state.
+ // This save can itself surface an unrelated conflict — one of the seed's own "today"
+ // fixtures (see prisma/seed.ts) is timed relative to whenever the seed happened to run,
+ // and can coincidentally fall just before the business's 10:00 opening — so handle either
+ // outcome rather than assuming a clean save.
+ await page.getByRole("switch", { name: `${targetDay} open` }).click();
+ await page.getByRole("button", { name: "Save", exact: true }).click();
+ const saveAnywayButton = page.getByRole("button", { name: "Save Anyway", exact: true });
+ const savedStatus = page.getByText("Saved. The booking engine reflects these hours immediately.");
+ await expect(saveAnywayButton.or(savedStatus)).toBeVisible();
+ if (await saveAnywayButton.isVisible()) {
+ await saveAnywayButton.click();
+ }
+ await expect(savedStatus).toBeVisible();
+ });
+
+ test("5. owner adds a new barber and gets a dev invite link", async ({ page }) => {
+ await signIn(page, SEEDED.owner.email, "password123");
+ await page.goto("/owner/team/add");
+
+ await expect(page.getByRole("heading", { name: "Add Staff" })).toBeVisible();
+ const email = uniqueEmail("e2e-new-barber");
+ const name = `E2E New Barber ${Date.now()}`;
+ await page.getByLabel("Name").fill(name);
+ await page.getByLabel("Email").fill(email);
+ await page.getByRole("button", { name: "Add Staff Member", exact: true }).click();
+
+ await expect(page.getByText(`${name} has been added.`)).toBeVisible();
+ await expect(page.getByText(/\/invite\/accept\?token=/)).toBeVisible();
+
+ await page.getByRole("button", { name: "Done", exact: true }).click();
+ await expect(page).toHaveURL(/\/owner\/team$/);
+ await page.getByPlaceholder("Search by name or email").fill(name);
+ await page.keyboard.press("Enter");
+ await expect(page.getByRole("link", { name: new RegExp(name) })).toBeVisible();
+ });
+
+ test("6. owner changes a barber's role and service eligibility", async ({ page }) => {
+ await signIn(page, SEEDED.owner.email, "password123");
+ await page.goto("/owner/team?q=Jamie");
+ await page.getByRole("link", { name: /Jamie Wallace/ }).click();
+
+ await expect(page.getByRole("heading", { name: "Jamie Wallace", level: 2 })).toBeVisible();
+
+ // Round-trip the role: Barber -> Owner -> Barber, confirming the save
+ // path (and last-owner protection isn't a factor for a non-owner).
+ const roleSelect = page.getByLabel("Role");
+ await expect(roleSelect).toHaveValue(/.+/);
+ await roleSelect.selectOption({ label: "Owner" });
+ await page.getByRole("button", { name: "Save Role", exact: true }).click();
+ await expect(page.getByText("Saved.").first()).toBeVisible();
+
+ await page.reload();
+ await page.getByLabel("Role").selectOption({ label: "Barber" });
+ await page.getByRole("button", { name: "Save Role", exact: true }).click();
+ await expect(page.getByText("Saved.").first()).toBeVisible();
+
+ // Jamie isn't seeded eligible for Haircut & Style — add it, save, then remove it again.
+ const haircutCheckbox = page.getByRole("checkbox", { name: SEEDED.services.haircut });
+ await expect(haircutCheckbox).not.toBeChecked();
+ await haircutCheckbox.check();
+ await page.getByRole("button", { name: "Save Services", exact: true }).click();
+ await expect(page.getByText("Saved.").first()).toBeVisible();
+
+ await page.reload();
+ await page.getByRole("checkbox", { name: SEEDED.services.haircut }).uncheck();
+ await page.getByRole("button", { name: "Save Services", exact: true }).click();
+ await expect(page.getByText("Saved.").first()).toBeVisible();
+ });
+
+ test("7. owner creates a service category and service, visible immediately on the customer booking flow", async ({ page }) => {
+ await signIn(page, SEEDED.owner.email, "password123");
+ await page.goto("/owner/services");
+ await expect(page.getByRole("heading", { name: "Services", exact: true })).toBeVisible();
+
+ const categoryName = `E2E Test Category ${Date.now()}`;
+ await page.getByPlaceholder("New category name").fill(categoryName);
+ await page.getByRole("button", { name: "Add Category", exact: true }).click();
+ await expect(page.getByRole("heading", { name: categoryName, level: 3 })).toBeVisible();
+
+ const categorySection = page.locator("section").filter({ has: page.getByRole("heading", { name: categoryName, level: 3 }) });
+ await categorySection.getByRole("button", { name: "Add Service", exact: true }).click();
+
+ const serviceName = `E2E Test Service ${Date.now()}`;
+ const sheet = page.getByRole("dialog");
+ await sheet.getByLabel("Name").fill(serviceName);
+ await sheet.getByLabel("Duration (min)").fill("30");
+ await sheet.getByLabel("Price").fill("15.00");
+ await sheet.getByRole("button", { name: "Add Service", exact: true }).click();
+
+ await expect(page.getByText(serviceName)).toBeVisible();
+
+ // The new service is immediately bookable by customers.
+ await page.goto("/book");
+ await expect(page.getByRole("button", { name: new RegExp(`^${serviceName}`) })).toBeVisible();
+ });
+
+ test("8. owner requires booking approval, and a new customer booking reflects it immediately", async ({ page }) => {
+ await signIn(page, SEEDED.owner.email, "password123");
+ await page.goto("/owner/settings/booking-rules");
+ await expect(page.getByRole("heading", { name: "Booking Rules" })).toBeVisible();
+
+ const approvalSwitch = page.getByRole("switch", { name: "Require approval for new bookings" });
+ await expect(approvalSwitch).toHaveAttribute("data-state", "unchecked");
+ await approvalSwitch.click();
+ await page.getByRole("button", { name: "Save", exact: true }).click();
+ await expect(page.getByText(/Saved — the customer booking flow uses these rules immediately/)).toBeVisible();
+
+ // A fresh guest booking now lands as PENDING, not CONFIRMED. Sign out first —
+ // signed in as Marcus, /book/details books as him rather than showing guest fields.
+ // Wait for the save's background requests to fully settle before clearing cookies:
+ // otherwise a still-in-flight response can re-set the session cookie right after
+ // it's cleared (a real race, confirmed by reproducing it outside Playwright too).
+ await page.waitForLoadState("networkidle");
+ await page.context().clearCookies();
+ await page.goto("/book");
+ await completeBookingFlow(page, {
+ serviceName: SEEDED.services.beardTrim,
+ guest: {
+ firstName: "E2E",
+ lastName: "ApprovalTest",
+ email: uniqueEmail("e2e-approval"),
+ phone: "+44 7700 900111",
+ },
+ });
+ await expect(page.getByText("Pending Approval")).toBeVisible();
+
+ // Restore the seeded default so later journeys (and re-runs) see auto-confirmed bookings again.
+ await signIn(page, SEEDED.owner.email, "password123");
+ await page.goto("/owner/settings/booking-rules");
+ await page.getByRole("switch", { name: "Require approval for new bookings" }).click();
+ await page.getByRole("button", { name: "Save", exact: true }).click();
+ await expect(page.getByText(/Saved — the customer booking flow uses these rules immediately/)).toBeVisible();
+ });
+
+ test("9. owner configures the loyalty programme", async ({ page }) => {
+ await signIn(page, SEEDED.owner.email, "password123");
+ await page.goto("/owner/settings/loyalty");
+ await expect(page.getByRole("heading", { name: "Loyalty Programme" })).toBeVisible();
+
+ const stampsInput = page.getByLabel("Stamps required for reward");
+ await expect(stampsInput).toHaveValue("10");
+ await stampsInput.fill("8");
+ await page.getByRole("button", { name: "Save", exact: true }).click();
+ await expect(page.getByText("Saved.").first()).toBeVisible();
+
+ await page.reload();
+ await expect(page.getByLabel("Stamps required for reward")).toHaveValue("8");
+
+ // Restore the seeded default.
+ await page.getByLabel("Stamps required for reward").fill("10");
+ await page.getByRole("button", { name: "Save", exact: true }).click();
+ await expect(page.getByText("Saved.").first()).toBeVisible();
+ });
+
+ test("10. owner redeems a customer's available loyalty reward on an appointment", async ({ page }) => {
+ await signIn(page, SEEDED.owner.email, "password123");
+ await page.goto("/owner/calendar?view=agenda");
+
+ // Seeded: Sam Osei has a CHECKED_IN appointment with Leo today, and sits
+ // exactly at the loyalty reward threshold with one AVAILABLE reward.
+ const card = page.locator(".rounded-jumbo").filter({ hasText: "Sam Osei" }).filter({ hasText: "Arrived" });
+ await card.getByRole("link", { name: "View", exact: true }).click();
+
+ await expect(page.getByText(/Subtotal/)).toBeVisible();
+ await page.getByRole("button", { name: "Redeem Reward", exact: true }).click();
+
+ const modal = page.getByRole("dialog");
+ await expect(modal.getByText(/This can't be undone/)).toBeVisible();
+ await modal.getByRole("button", { name: "Apply Reward", exact: true }).click();
+ await expect(modal.getByText(/has been applied to this appointment/)).toBeVisible();
+ await modal.getByRole("button", { name: "Done", exact: true }).click();
+
+ await expect(page.getByText("Loyalty reward")).toBeVisible();
+ });
+
+ test("11. a barber without MANAGE_STAFF is denied access to owner administration", async ({ page }) => {
+ await signIn(page, SEEDED.barberHaircutOnly.email, "password123");
+
+ await page.goto("/owner");
+ await expect(page).toHaveURL(/\/staff$/);
+ await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Dashboard" })).toHaveCount(0);
+ });
+
+ test("12. the owner switches back to barber tools without losing the barber nav", async ({ page }) => {
+ await signIn(page, SEEDED.owner.email, "password123");
+ await page.goto("/owner");
+
+ await page.getByRole("link", { name: "Barber Tools", exact: true }).click();
+ await expect(page).toHaveURL(/\/staff$/);
+ await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
+ await expect(page.getByRole("link", { name: "Current", exact: true })).toBeVisible();
+ });
+});
diff --git a/prisma/migrations/20260803070350_owner_administration/migration.sql b/prisma/migrations/20260803070350_owner_administration/migration.sql
new file mode 100644
index 0000000..dfc2112
--- /dev/null
+++ b/prisma/migrations/20260803070350_owner_administration/migration.sql
@@ -0,0 +1,55 @@
+-- AlterEnum
+-- This migration adds more than one value to an enum.
+-- With PostgreSQL versions 11 and earlier, this is not possible
+-- in a single migration. This can be worked around by creating
+-- multiple migrations, each migration adding only one value to
+-- the enum.
+
+
+ALTER TYPE "Permission" ADD VALUE 'MANAGE_ALL_APPOINTMENTS';
+ALTER TYPE "Permission" ADD VALUE 'VIEW_REPORTING';
+
+-- AlterTable
+ALTER TABLE "Appointment" ADD COLUMN "discountAmount" DECIMAL(10,2);
+
+-- AlterTable
+ALTER TABLE "LoyaltyReward" ADD COLUMN "redeemedByStaffId" TEXT;
+
+-- CreateTable
+CREATE TABLE "StaffInvitation" (
+ "id" TEXT NOT NULL,
+ "businessId" TEXT NOT NULL,
+ "staffId" TEXT NOT NULL,
+ "email" TEXT NOT NULL,
+ "token" TEXT NOT NULL,
+ "invitedByUserId" TEXT,
+ "expiresAt" TIMESTAMP(3) NOT NULL,
+ "acceptedAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "StaffInvitation_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "StaffInvitation_staffId_key" ON "StaffInvitation"("staffId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "StaffInvitation_token_key" ON "StaffInvitation"("token");
+
+-- CreateIndex
+CREATE INDEX "StaffInvitation_businessId_idx" ON "StaffInvitation"("businessId");
+
+-- AddForeignKey
+ALTER TABLE "StaffInvitation" ADD CONSTRAINT "StaffInvitation_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "StaffInvitation" ADD CONSTRAINT "StaffInvitation_staffId_fkey" FOREIGN KEY ("staffId") REFERENCES "StaffProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "StaffInvitation" ADD CONSTRAINT "StaffInvitation_invitedByUserId_fkey" FOREIGN KEY ("invitedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_appliedToAppointmentId_fkey" FOREIGN KEY ("appliedToAppointmentId") REFERENCES "Appointment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_redeemedByStaffId_fkey" FOREIGN KEY ("redeemedByStaffId") REFERENCES "StaffProfile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/prisma/migrations/20260803071240_loyalty_free_service/migration.sql b/prisma/migrations/20260803071240_loyalty_free_service/migration.sql
new file mode 100644
index 0000000..63f52d3
--- /dev/null
+++ b/prisma/migrations/20260803071240_loyalty_free_service/migration.sql
@@ -0,0 +1,5 @@
+-- AlterTable
+ALTER TABLE "LoyaltyProgramme" ADD COLUMN "freeServiceId" TEXT;
+
+-- AddForeignKey
+ALTER TABLE "LoyaltyProgramme" ADD CONSTRAINT "LoyaltyProgramme_freeServiceId_fkey" FOREIGN KEY ("freeServiceId") REFERENCES "Service"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index c179ccc..a4e42d0 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -47,12 +47,20 @@ enum Permission {
MANAGE_LOYALTY_PROGRAMME
MANAGE_NOTIFICATION_SETTINGS
VIEW_ALL_STAFF_APPOINTMENTS
+ /// Distinct from VIEW_ALL_STAFF_APPOINTMENTS — reassign/reschedule/cancel
+ /// any staff member's appointment, not just look at it. See
+ /// docs/phase-6-summary.md, "Permission and business-isolation rules".
+ MANAGE_ALL_APPOINTMENTS
MANAGE_OWN_APPOINTMENTS
CHECK_IN_CUSTOMERS
MANAGE_LOYALTY_STAMPS
MANAGE_OWN_AVAILABILITY
VIEW_CUSTOMER_DIRECTORY
BOOK_ON_BEHALF_OF_CUSTOMER
+ /// Gates the owner dashboard specifically — kept separate from
+ /// MANAGE_STAFF/MANAGE_BUSINESS_PROFILE so "can see aggregate business
+ /// health" is its own grantable capability.
+ VIEW_REPORTING
}
enum StaffStatus {
@@ -164,6 +172,7 @@ model Business {
roles Role[]
appointments Appointment[]
loyaltyProgramme LoyaltyProgramme?
+ staffInvitations StaffInvitation[]
}
/// Split from Business (rather than address fields on Business itself) so
@@ -296,6 +305,8 @@ model User {
cancelledAppointments Appointment[] @relation("AppointmentCancelledBy")
/// Whoever initiated a booking — see Appointment.createdByUserId.
createdAppointments Appointment[] @relation("AppointmentCreatedBy")
+ /// Owner-side: invitations this user has sent (see StaffInvitation).
+ sentStaffInvitations StaffInvitation[] @relation("StaffInvitationInvitedBy")
}
model Account {
@@ -398,6 +409,10 @@ model StaffProfile {
preferredByCustomers CustomerProfile[] @relation("PreferredBarber")
authoredLoyaltyTransactions LoyaltyTransaction[] @relation("LoyaltyTransactionAuthor")
authoredCustomerNotes CustomerNote[] @relation("CustomerNoteAuthor")
+ redeemedLoyaltyRewards LoyaltyReward[] @relation("LoyaltyRewardRedeemedBy")
+ /// Set when this profile was created via the owner "Add Staff" flow
+ /// rather than seeded directly — see StaffInvitationService.
+ invitation StaffInvitation?
@@index([businessId])
}
@@ -434,6 +449,37 @@ model RolePermission {
@@unique([roleId, permission])
}
+/// Created immediately alongside the StaffProfile/User when an owner adds a
+/// staff member (see StaffInvitationService) — the profile is usable right
+/// away (it already has a role, can appear on the calendar, etc.); this row
+/// only tracks the "set your own password" step, so the UI can show
+/// Pending vs Accepted without gating functionality on it. No real email
+/// delivery this phase — `token` is surfaced as a copy-link for the owner
+/// to send manually, same documented limitation as Phase 5's loyalty
+/// redemption note (see docs/phase-6-summary.md).
+model StaffInvitation {
+ id String @id @default(cuid())
+
+ businessId String
+ business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
+
+ staffId String @unique
+ staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade)
+
+ email String
+ token String @unique
+
+ invitedByUserId String?
+ invitedByUser User? @relation("StaffInvitationInvitedBy", fields: [invitedByUserId], references: [id], onDelete: SetNull)
+
+ expiresAt DateTime
+ acceptedAt DateTime?
+
+ createdAt DateTime @default(now())
+
+ @@index([businessId])
+}
+
// ─────────────────────────────────────────────────────────────────────────
// Catalog
// ─────────────────────────────────────────────────────────────────────────
@@ -479,9 +525,10 @@ model Service {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- staff StaffService[]
- appointmentServices AppointmentService[]
- loyaltyEligibleFor LoyaltyEligibleService[]
+ staff StaffService[]
+ appointmentServices AppointmentService[]
+ loyaltyEligibleFor LoyaltyEligibleService[]
+ loyaltyProgrammesUsingAsFreeReward LoyaltyProgramme[]
@@index([businessId])
@@index([categoryId])
@@ -605,6 +652,14 @@ model Appointment {
notes String? @db.Text
+ /// Snapshot of the amount deducted by a redeemed loyalty reward, if any —
+ /// see LoyaltyService.redeemReward. Null = no reward applied. The
+ /// original AppointmentService.priceAtBooking rows are never rewritten;
+ /// the payable total is subtotal (sum of those) minus this value,
+ /// computed at read time rather than also stored, so there is exactly
+ /// one place a future price/discount bug could hide, not two.
+ discountAmount Decimal? @db.Decimal(10, 2)
+
/// Cancellation is a status transition, never a delete — these three
/// fields are only ever set together, when status becomes CANCELLED.
cancellationReason String?
@@ -623,9 +678,13 @@ model Appointment {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- services AppointmentService[]
- loyaltyTransactions LoyaltyTransaction[]
- notifications Notification[]
+ services AppointmentService[]
+ loyaltyTransactions LoyaltyTransaction[]
+ notifications Notification[]
+ /// Almost always zero or one row — a reward is applied to at most one
+ /// appointment (see LoyaltyReward.appliedToAppointmentId) — but modelled
+ /// as the array Prisma requires for a one-to-many back-relation.
+ appliedLoyaltyRewards LoyaltyReward[]
@@index([staffId, startAt])
@@index([businessId, startAt])
@@ -671,6 +730,12 @@ model LoyaltyProgramme {
rewardLabel String?
maxRewardValue Decimal? @db.Decimal(10, 2)
+ /// Which service is free when rewardType is FREE_SERVICE — null for the
+ /// other two reward types. See LoyaltyService.validateProgrammeConfig for
+ /// the "must reference an active service" rule.
+ freeServiceId String?
+ freeService Service? @relation(fields: [freeServiceId], references: [id], onDelete: SetNull)
+
autoApplyReward Boolean @default(true)
requiresStaffApproval Boolean @default(false)
@@ -774,8 +839,13 @@ model LoyaltyReward {
status RewardStatus @default(AVAILABLE)
appliedToAppointmentId String?
- redeemedAt DateTime?
- expiresAt DateTime?
+ appliedToAppointment Appointment? @relation(fields: [appliedToAppointmentId], references: [id], onDelete: SetNull)
+
+ redeemedAt DateTime?
+ redeemedByStaffId String?
+ redeemedByStaff StaffProfile? @relation("LoyaltyRewardRedeemedBy", fields: [redeemedByStaffId], references: [id], onDelete: SetNull)
+
+ expiresAt DateTime?
createdAt DateTime @default(now())
diff --git a/prisma/seed.ts b/prisma/seed.ts
index ae00ae9..5f9e85c 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -27,6 +27,25 @@ function minutesFromNow(minutes: number): Date {
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });
+/**
+ * `role.upsert`'s `update: {}` deliberately does nothing on an existing
+ * role (see the two call sites below) — re-running the seed must still be
+ * safe to call repeatedly without duplicating anything, but a plain
+ * nested `permissions: { create: [...] }` only fires on the `create`
+ * branch, so a permission added to OWNER_PERMISSIONS/BARBER_PERMISSIONS
+ * after a business was first seeded would silently never reach an
+ * already-seeded database. This syncs RolePermission rows to exactly
+ * match the target list every run — found and fixed while adding Phase 6
+ * permissions to a database seeded back in Phase 2.
+ */
+async function syncRolePermissions(roleId: string, permissions: Permission[]): Promise {
+ await prisma.rolePermission.deleteMany({ where: { roleId, permission: { notIn: permissions } } });
+ await prisma.rolePermission.createMany({
+ data: permissions.map((permission) => ({ roleId, permission })),
+ skipDuplicates: true,
+ });
+}
+
const OWNER_PERMISSIONS: Permission[] = [
Permission.MANAGE_BUSINESS_PROFILE,
Permission.MANAGE_BOOKING_RULES,
@@ -35,12 +54,14 @@ const OWNER_PERMISSIONS: Permission[] = [
Permission.MANAGE_LOYALTY_PROGRAMME,
Permission.MANAGE_NOTIFICATION_SETTINGS,
Permission.VIEW_ALL_STAFF_APPOINTMENTS,
+ Permission.MANAGE_ALL_APPOINTMENTS,
Permission.MANAGE_OWN_APPOINTMENTS,
Permission.CHECK_IN_CUSTOMERS,
Permission.MANAGE_LOYALTY_STAMPS,
Permission.MANAGE_OWN_AVAILABILITY,
Permission.VIEW_CUSTOMER_DIRECTORY,
Permission.BOOK_ON_BEHALF_OF_CUSTOMER,
+ Permission.VIEW_REPORTING,
];
const BARBER_PERMISSIONS: Permission[] = [
@@ -123,6 +144,7 @@ async function main() {
permissions: { create: OWNER_PERMISSIONS.map((permission) => ({ permission })) },
},
});
+ await syncRolePermissions(ownerRole.id, OWNER_PERMISSIONS);
const barberRole = await prisma.role.upsert({
where: { businessId_name: { businessId: business.id, name: "Barber" } },
@@ -134,6 +156,7 @@ async function main() {
permissions: { create: BARBER_PERMISSIONS.map((permission) => ({ permission })) },
},
});
+ await syncRolePermissions(barberRole.id, BARBER_PERMISSIONS);
const category = await prisma.serviceCategory.upsert({
where: { id: `${business.id}-senior-barber` },
@@ -716,6 +739,219 @@ async function main() {
});
}
+ // ── Phase 6: owner administration fixtures ─────────────────────────────
+
+ // A service with no eligible staff — exercises the owner dashboard's
+ // "service has no eligible barber" configuration warning.
+ await prisma.service.upsert({
+ where: { id: `${business.id}-kids-haircut` },
+ update: {},
+ create: {
+ id: `${business.id}-kids-haircut`,
+ businessId: business.id,
+ categoryId: category.id,
+ name: "Kids Haircut (Under 12)",
+ durationMinutes: 20,
+ price: 12,
+ },
+ });
+
+ // An inactive staff member — exercises the "Inactive" filter and
+ // reactivation flow on the owner Team page.
+ const inactivePasswordHash = await bcrypt.hash("password123", 12);
+ const inactiveStaffUser = await prisma.user.upsert({
+ where: { email: "priya.shah@blakeheritage.co.uk" },
+ update: {},
+ create: {
+ email: "priya.shah@blakeheritage.co.uk",
+ name: "Priya Shah",
+ passwordHash: inactivePasswordHash,
+ emailVerified: new Date(),
+ staffProfile: {
+ create: {
+ businessId: business.id,
+ roleId: barberRole.id,
+ status: "INACTIVE",
+ bio: "Former Saturday specialist, currently on extended leave.",
+ services: { create: [{ serviceId: beardTrim.id }] },
+ },
+ },
+ },
+ include: { staffProfile: true },
+ });
+
+ // A pending staff invitation — a new hire the owner has added but who
+ // hasn't accepted yet, exercising the "Pending" badge and "Get Invite
+ // Link" flow. No passwordHash/emailVerified — matches a real
+ // not-yet-accepted invitee.
+ const pendingStaffUser = await prisma.user.upsert({
+ where: { email: "owen.clarke@blakeheritage.co.uk" },
+ update: {},
+ create: {
+ email: "owen.clarke@blakeheritage.co.uk",
+ name: "Owen Clarke",
+ staffProfile: {
+ create: {
+ businessId: business.id,
+ roleId: barberRole.id,
+ bio: "Joining the team next month.",
+ services: { create: [{ serviceId: haircut.id }] },
+ },
+ },
+ },
+ include: { staffProfile: true },
+ });
+ if (pendingStaffUser.staffProfile) {
+ await prisma.staffInvitation.upsert({
+ where: { staffId: pendingStaffUser.staffProfile.id },
+ update: {},
+ create: {
+ businessId: business.id,
+ staffId: pendingStaffUser.staffProfile.id,
+ email: pendingStaffUser.email!,
+ token: "dev-seed-invite-owen-clarke",
+ invitedByUserId: ownerUser.id,
+ expiresAt: daysFromNow(7, 12),
+ },
+ });
+ }
+
+ // A genuinely future (not-today), non-cancelled appointment for Leo that
+ // isn't part of any other status-variety fixture above — exercises the
+ // owner's "affected appointments" conflict review when tightening opening
+ // hours or deactivating a staff member with upcoming bookings. Uses Priya
+ // Kapoor (not Alex Chen) to avoid the Phase 4/5 e2e "book again" fixture.
+ if (barberUser.staffProfile) {
+ const leoFutureStart = daysFromNow(4, 12, 0);
+ await prisma.appointment.upsert({
+ where: { id: `${business.id}-appt-leo-future` },
+ update: {},
+ create: {
+ id: `${business.id}-appt-leo-future`,
+ businessId: business.id,
+ customerId: secondGuestCustomer.id,
+ staffId: barberUser.staffProfile.id,
+ status: "CONFIRMED",
+ startAt: leoFutureStart,
+ endAt: new Date(leoFutureStart.getTime() + haircut.durationMinutes * 60_000),
+ services: {
+ create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }],
+ },
+ },
+ });
+ }
+
+ // A completed past visit with an already-redeemed reward — exercises
+ // redemption history (as opposed to Sam's still-AVAILABLE reward above),
+ // and the appointment's discountAmount/final-total display.
+ if (barberUser.staffProfile) {
+ const leo = barberUser.staffProfile;
+ const jordanRedeemedApptStart = daysFromNow(-30, 11, 0);
+ const jordanRedeemedAppt = await prisma.appointment.upsert({
+ where: { id: `${business.id}-appt-jordan-redeemed` },
+ update: {},
+ create: {
+ id: `${business.id}-appt-jordan-redeemed`,
+ businessId: business.id,
+ customerId: guestCustomer.id,
+ staffId: leo.id,
+ status: "COMPLETED",
+ startAt: jordanRedeemedApptStart,
+ endAt: new Date(jordanRedeemedApptStart.getTime() + haircut.durationMinutes * 60_000),
+ discountAmount: 11.0,
+ services: {
+ create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }],
+ },
+ },
+ });
+
+ const loyaltyProgramme = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: business.id } });
+ const jordanAccount = await prisma.loyaltyAccount.upsert({
+ where: { customerId_loyaltyProgrammeId: { customerId: guestCustomer.id, loyaltyProgrammeId: loyaltyProgramme.id } },
+ update: {},
+ create: { customerId: guestCustomer.id, loyaltyProgrammeId: loyaltyProgramme.id, currentStamps: 2 },
+ });
+ const jordanTxCount = await prisma.loyaltyTransaction.count({ where: { loyaltyAccountId: jordanAccount.id } });
+ if (jordanTxCount === 0) {
+ await prisma.loyaltyTransaction.createMany({
+ data: [
+ ...Array.from({ length: loyaltyProgramme.stampsRequired }, () => ({
+ loyaltyAccountId: jordanAccount.id,
+ type: "STAMP_ADDED" as const,
+ amount: 1,
+ authoredByStaffId: leo.id,
+ })),
+ {
+ loyaltyAccountId: jordanAccount.id,
+ type: "REWARD_REDEEMED" as const,
+ amount: -loyaltyProgramme.stampsRequired,
+ authoredByStaffId: leo.id,
+ appointmentId: jordanRedeemedAppt.id,
+ },
+ // Two stamps earned since redeeming, keeping currentStamps in sync.
+ { loyaltyAccountId: jordanAccount.id, type: "STAMP_ADDED" as const, amount: 1, authoredByStaffId: leo.id },
+ { loyaltyAccountId: jordanAccount.id, type: "STAMP_ADDED" as const, amount: 1, authoredByStaffId: leo.id },
+ ],
+ });
+ await prisma.loyaltyReward.create({
+ data: {
+ loyaltyAccountId: jordanAccount.id,
+ rewardType: loyaltyProgramme.rewardType,
+ value: loyaltyProgramme.rewardValue,
+ label: loyaltyProgramme.rewardLabel ?? "Reward",
+ status: "REDEEMED",
+ appliedToAppointmentId: jordanRedeemedAppt.id,
+ redeemedAt: jordanRedeemedApptStart,
+ redeemedByStaffId: leo.id,
+ },
+ });
+ }
+
+ // ── Audit log: representative owner-action history ────────────────────
+ const auditLogCount = await prisma.auditLog.count({ where: { entityType: "Business", entityId: business.id } });
+ if (auditLogCount === 0) {
+ await prisma.auditLog.createMany({
+ data: [
+ {
+ actorUserId: ownerUser.id,
+ action: "BUSINESS_PROFILE_UPDATED",
+ entityType: "Business",
+ entityId: business.id,
+ metadata: { fields: ["description", "phone"] },
+ },
+ {
+ actorUserId: ownerUser.id,
+ action: "STAFF_ADDED",
+ entityType: "StaffProfile",
+ entityId: pendingStaffUser.staffProfile?.id ?? business.id,
+ metadata: { name: "Owen Clarke", role: "Barber" },
+ },
+ {
+ actorUserId: ownerUser.id,
+ action: "STAFF_DEACTIVATED",
+ entityType: "StaffProfile",
+ entityId: inactiveStaffUser.staffProfile?.id ?? business.id,
+ metadata: { name: "Priya Shah" },
+ },
+ {
+ actorUserId: ownerUser.id,
+ action: "LOYALTY_REWARD_REDEEMED",
+ entityType: "LoyaltyReward",
+ entityId: jordanAccount.id,
+ metadata: { customer: "Jordan Blake", appointmentId: jordanRedeemedAppt.id },
+ },
+ {
+ actorUserId: ownerUser.id,
+ action: "LOYALTY_CONFIG_UPDATED",
+ entityType: "LoyaltyProgramme",
+ entityId: loyaltyProgramme.id,
+ metadata: { isEnabled: true, rewardType: loyaltyProgramme.rewardType },
+ },
+ ],
+ });
+ }
+ }
+
console.log("Seed complete:", {
business: business.name,
roles: [ownerRole.name, barberRole.name],
diff --git a/src/app/(auth)/invite/accept/page.tsx b/src/app/(auth)/invite/accept/page.tsx
new file mode 100644
index 0000000..b7d4784
--- /dev/null
+++ b/src/app/(auth)/invite/accept/page.tsx
@@ -0,0 +1,51 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { prisma } from "@/lib/db/prisma";
+import { validateInvitationToken } from "@/services/staff/staff-invitation.service";
+import { AcceptInvitationForm } from "@/features/auth/components/accept-invitation-form";
+
+export const metadata: Metadata = { title: "Accept Invitation" };
+export const dynamic = "force-dynamic";
+
+export default async function AcceptInvitationPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ token?: string }>;
+}) {
+ const { token } = await searchParams;
+
+ if (!token) {
+ return ;
+ }
+
+ const check = await validateInvitationToken(prisma, token);
+ if (!check.valid) {
+ const message =
+ check.reason === "EXPIRED"
+ ? "This invitation link has expired. Ask the business owner to send you a new one."
+ : check.reason === "ALREADY_ACCEPTED"
+ ? "This invitation has already been used."
+ : "This invitation link isn't valid.";
+ return ;
+ }
+
+ return (
+
);
diff --git a/src/app/owner/appointments/[id]/reschedule/layout.tsx b/src/app/owner/appointments/[id]/reschedule/layout.tsx
new file mode 100644
index 0000000..4e760fc
--- /dev/null
+++ b/src/app/owner/appointments/[id]/reschedule/layout.tsx
@@ -0,0 +1,10 @@
+/**
+ * Deliberately outside the (owner) route group, same reasoning as
+ * src/app/staff/appointments/[id]/reschedule/layout.tsx: this page reuses
+ * BarberDateTimePicker, whose own fixed bottom action bar would otherwise
+ * sit under the persistent owner sidebar/bottom-nav and swallow clicks
+ * meant for "Confirm Reschedule".
+ */
+export default function OwnerRescheduleLayout({ children }: { children: React.ReactNode }) {
+ return
{children}
;
+}
diff --git a/src/app/owner/appointments/[id]/reschedule/page.tsx b/src/app/owner/appointments/[id]/reschedule/page.tsx
new file mode 100644
index 0000000..34ccc2b
--- /dev/null
+++ b/src/app/owner/appointments/[id]/reschedule/page.tsx
@@ -0,0 +1,111 @@
+import type { Metadata } from "next";
+import { notFound, redirect } from "next/navigation";
+import { auth } from "@/auth";
+import { Card } from "@/components/ui/card";
+import { TopNav } from "@/components/navigation/top-nav";
+import { prisma } from "@/lib/db/prisma";
+import { hasPermission } from "@/lib/auth/permissions";
+import { Permission } from "@/generated/prisma/enums";
+import { getAppointmentById } from "@/services/scheduling/appointment.service";
+import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
+import { ownerRescheduleAppointmentAction } from "@/features/owner/actions/appointments";
+import { BarberDateTimePicker } from "@/features/booking/components/barber-date-time-picker";
+
+export const metadata: Metadata = { title: "Reschedule / Reassign" };
+export const dynamic = "force-dynamic";
+
+const NON_RESCHEDULABLE = new Set(["COMPLETED", "CANCELLED", "NO_SHOW", "IN_PROGRESS"]);
+
+/**
+ * Also the owner's appointment-reassignment surface — BarberDateTimePicker
+ * already lets the caller choose a different eligible barber as part of
+ * picking a new slot, so "reassign to another barber" and "reschedule to a
+ * new time" are the same underlying operation here, not two separate
+ * flows. See docs/phase-6-summary.md, "Appointment reassignment approach".
+ */
+export default async function OwnerRescheduleAppointmentPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const session = await auth();
+ if (!session?.user) redirect("/sign-in");
+
+ const canManage = await hasPermission(session.user.id, Permission.MANAGE_ALL_APPOINTMENTS);
+ if (!canManage) redirect("/staff");
+
+ const owner = await prisma.staffProfile.findUnique({
+ where: { userId: session.user.id },
+ include: { business: { select: { id: true, timezone: true } } },
+ });
+ if (!owner) redirect("/staff");
+
+ const { id } = await params;
+ const appointment = await getAppointmentById(prisma, id);
+ if (!appointment) notFound();
+
+ try {
+ assertAppointmentInBusiness(appointment, { id: owner.id, businessId: owner.businessId });
+ } catch (error) {
+ if (error instanceof StaffPermissionDeniedError) notFound();
+ throw error;
+ }
+
+ if (NON_RESCHEDULABLE.has(appointment.status)) {
+ redirect(`/owner/appointments/${id}`);
+ }
+
+ async function handleContinue(selection: { staffId?: string; date: string; startAt: string }) {
+ "use server";
+ const result = await ownerRescheduleAppointmentAction({
+ appointmentId: id,
+ staffId: selection.staffId,
+ startAt: selection.startAt,
+ });
+ if (result.success) {
+ redirect(`/owner/appointments/${id}`);
+ }
+ return result;
+ }
+
+ return (
+
+ These hours would leave {conflicts.length} upcoming appointment{conflicts.length > 1 ? "s" : ""} outside
+ the business's open hours. They won't be cancelled automatically — review them, then confirm if
+ you still want to save.
+
+ This staff member still has {deactivateConflicts.length} upcoming appointment
+ {deactivateConflicts.length > 1 ? "s" : ""}. Deactivating won't cancel them — they'll need to
+ be reassigned or handled separately.
+
+
+ {deactivateConflicts.map((c) => (
+
+
+ {c.customerName}
+
+
{c.serviceNames}
+
+ ))}
+
+
+
+
+
+
+ ) : (
+ <>
+ {deactivateError && (
+
+ {deactivateError}
+
+ )}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/features/staff/components/appointment-status-actions.tsx b/src/features/staff/components/appointment-status-actions.tsx
index 2382a8c..a5c897b 100644
--- a/src/features/staff/components/appointment-status-actions.tsx
+++ b/src/features/staff/components/appointment-status-actions.tsx
@@ -13,11 +13,31 @@ import {
markNoShowAction,
startAppointmentAction,
} from "../actions/appointment-actions";
+import type { StaffAppointmentActionResult } from "../actions/appointment-actions";
type RawStatus =
"PENDING" | "CONFIRMED" | "CHECKED_IN" | "IN_PROGRESS" | "COMPLETED" | "CANCELLED" | "NO_SHOW";
type ConfirmKind = "complete" | "no_show" | "cancel";
+/** Same shape the owner variant (src/features/owner/actions/appointments.ts) implements — see AppointmentStatusActions' `actions` prop. */
+export interface AppointmentActionSet {
+ confirm: (appointmentId: string) => Promise;
+ checkIn: (appointmentId: string) => Promise;
+ start: (appointmentId: string) => Promise;
+ complete: (appointmentId: string) => Promise;
+ markNoShow: (appointmentId: string) => Promise;
+ cancel: (params: { appointmentId: string; reason?: string }) => Promise;
+}
+
+const DEFAULT_ACTIONS: AppointmentActionSet = {
+ confirm: confirmAppointmentAction,
+ checkIn: checkInAppointmentAction,
+ start: startAppointmentAction,
+ complete: completeAppointmentAction,
+ markNoShow: markNoShowAction,
+ cancel: cancelStaffAppointmentAction,
+};
+
/**
* The status-appropriate action set — shared by Current Appointment and
* Appointment Details, since both need exactly the same buttons for the
@@ -25,14 +45,19 @@ type ConfirmKind = "complete" | "no_show" | "cancel";
* AppointmentService's own state machine; this component never decides
* whether a transition is legal, only which buttons to *offer* for a given
* status (offering an illegal one would just get rejected server-side —
- * see appointment-actions.ts).
+ * see appointment-actions.ts). `actions` defaults to the staff (own-only)
+ * Server Actions; the owner appointment details page passes the owner
+ * variant instead, so this one component serves both contexts without
+ * forking — see docs/phase-6-summary.md, "Owner appointment access".
*/
export function AppointmentStatusActions({
appointmentId,
status,
+ actions = DEFAULT_ACTIONS,
}: {
appointmentId: string;
status: RawStatus;
+ actions?: AppointmentActionSet;
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
@@ -59,10 +84,10 @@ export function AppointmentStatusActions({
startTransition(async () => {
const result =
confirmKind === "complete"
- ? await completeAppointmentAction(appointmentId)
+ ? await actions.complete(appointmentId)
: confirmKind === "no_show"
- ? await markNoShowAction(appointmentId)
- : await cancelStaffAppointmentAction({ appointmentId, reason: reason || undefined });
+ ? await actions.markNoShow(appointmentId)
+ : await actions.cancel({ appointmentId, reason: reason || undefined });
if (!result.success) {
setError(result.error ?? "Something went wrong.");
return;
@@ -101,7 +126,7 @@ export function AppointmentStatusActions({
@@ -110,7 +135,7 @@ export function AppointmentStatusActions({
@@ -119,7 +144,7 @@ export function AppointmentStatusActions({
diff --git a/src/features/staff/components/date-strip.tsx b/src/features/staff/components/date-strip.tsx
index 9e3c43f..c589d26 100644
--- a/src/features/staff/components/date-strip.tsx
+++ b/src/features/staff/components/date-strip.tsx
@@ -15,15 +15,23 @@ export function DateStrip({
businessTimezone,
basePath,
view,
+ extraParams,
}: {
selectedDate: CalendarDate;
businessTimezone: string;
basePath: string;
view?: string;
+ /** Preserved across date navigation — e.g. the owner calendar's `?staffId=` filter. */
+ extraParams?: Record;
}) {
const today = todayIn(businessTimezone);
const viewParam = view ? `&view=${view}` : "";
- const hrefFor = (date: CalendarDate) => `${basePath}?date=${date}${viewParam}`;
+ const extraParamsString = extraParams
+ ? Object.entries(extraParams)
+ .map(([key, value]) => `&${key}=${encodeURIComponent(value)}`)
+ .join("")
+ : "";
+ const hrefFor = (date: CalendarDate) => `${basePath}?date=${date}${viewParam}${extraParamsString}`;
const days = Array.from({ length: 7 }, (_, i) => addDays(selectedDate, i - 3));
const label = new Date(`${selectedDate}T00:00:00`).toLocaleDateString("en-GB", {
diff --git a/src/features/staff/components/day-calendar.tsx b/src/features/staff/components/day-calendar.tsx
index e6096de..017d985 100644
--- a/src/features/staff/components/day-calendar.tsx
+++ b/src/features/staff/components/day-calendar.tsx
@@ -52,6 +52,7 @@ export function DayCalendar({
blocks,
workingWindow,
currentTimeMinutes,
+ appointmentHrefBase = "/staff/appointments",
}: {
appointments: CalendarAppointment[];
blocks: CalendarBlock[];
@@ -59,6 +60,8 @@ export function DayCalendar({
workingWindow: { startMinutes: number; endMinutes: number } | null;
/** Minutes-from-midnight right now, only passed when the viewed day is today. */
currentTimeMinutes: number | null;
+ /** Owner's all-staff calendar reuses this component unchanged but links into /owner/appointments/[id] instead — see docs/phase-6-summary.md, "All-staff calendar approach". */
+ appointmentHrefBase?: string;
}) {
const hourMarks = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i);
const gridHeight = (END_HOUR - START_HOUR) * PX_PER_HOUR;
@@ -121,7 +124,7 @@ export function DayCalendar({
{appointments.map((appt) => (
}) {
+export function WorkingHoursForm({
+ initialRows,
+ businessHoursLabel,
+ onSave = saveWorkingHoursAction,
+}: {
+ initialRows: InitialWorkingHoursRow[];
+ businessHoursLabel: Record;
+ /** Owner-on-behalf-of-staff editing (src/app/(owner)/owner/team/[id]/hours) injects its own Server Action here instead of the self-service default — same pattern as AppointmentStatusActions' `actions` prop. */
+ onSave?: (days: WorkingHoursDayInput[]) => Promise;
+}) {
const router = useRouter();
const [days, setDays] = useState(() =>
DAY_ORDER.map((dayOfWeek) => {
@@ -84,7 +95,7 @@ export function WorkingHoursForm({ initialRows, businessHoursLabel }: { initialR
const handleSave = () => {
setError(null);
startTransition(async () => {
- const result = await saveWorkingHoursAction(
+ const result = await onSave(
days.map((d) => ({
dayOfWeek: d.dayOfWeek,
isOff: d.isOff,
diff --git a/src/lib/audit/write-audit-log.ts b/src/lib/audit/write-audit-log.ts
new file mode 100644
index 0000000..2157149
--- /dev/null
+++ b/src/lib/audit/write-audit-log.ts
@@ -0,0 +1,30 @@
+import type { Prisma, PrismaClient } from "@/generated/prisma/client";
+
+/**
+ * A thin, typed wrapper over the (previously unused, see schema.prisma)
+ * AuditLog table — used for owner actions that change shared business
+ * configuration (staff, roles, services, hours, booking rules, loyalty,
+ * reward redemption). Never stores passwords, invitation tokens, or other
+ * secrets in `metadata` — callers pass only display-safe before/after
+ * values.
+ */
+export async function writeAuditLog(
+ prisma: PrismaClient,
+ params: {
+ actorUserId: string | null;
+ action: string;
+ entityType: string;
+ entityId: string;
+ metadata?: Prisma.InputJsonValue;
+ },
+): Promise {
+ await prisma.auditLog.create({
+ data: {
+ actorUserId: params.actorUserId,
+ action: params.action,
+ entityType: params.entityType,
+ entityId: params.entityId,
+ metadata: params.metadata,
+ },
+ });
+}
diff --git a/src/lib/navigation/staff-nav-items.ts b/src/lib/navigation/staff-nav-items.ts
index 0923df6..0ffe3ba 100644
--- a/src/lib/navigation/staff-nav-items.ts
+++ b/src/lib/navigation/staff-nav-items.ts
@@ -1,14 +1,15 @@
import type { BottomNavItem } from "@/components/navigation/bottom-nav";
/**
- * Single source of truth for the staff/owner bottom nav. The templates had
- * two competing 4-item sets for what is conceptually the same nav
+ * Single source of truth for the staff bottom nav. The templates had two
+ * competing 4-item sets for what is conceptually the same nav
* (Calendar/Current/Clients/Settings on most staff+owner screens vs.
* Dashboard/Staff/Calendar/Admin on the Team Management screen — see
- * docs/implementation-plan.md §5/§8). Owner-only screens (team, business
- * settings, etc.) are reached from Settings rather than getting their own
- * top-level tab, so this one list serves both the (staff) and (owner)
- * route groups.
+ * docs/implementation-plan.md §5/§8). Phase 5 resolved that by having
+ * (owner) reuse this same list; Phase 6 gives (owner) its own
+ * OWNER_NAV_ITEMS below instead, per that phase's own explicit nav
+ * requirement (Dashboard/Calendar/Team/Services/Settings) — see
+ * docs/phase-6-summary.md, "Owner navigation".
*
* Icons are string keys, not component references — this module is
* imported by Server Component layouts, and a Lucide icon component isn't
@@ -21,3 +22,12 @@ export const STAFF_NAV_ITEMS: BottomNavItem[] = [
{ href: "/staff/clients", label: "Clients", icon: "users" },
{ href: "/staff/settings", label: "Settings", icon: "settings" },
];
+
+/** The (owner) route group's own nav — distinct from STAFF_NAV_ITEMS per the Phase 6 brief. "Team" covers staff management (incl. individual staff detail); "Services" covers categories+services; business/booking-rules/loyalty/notifications live under Settings. */
+export const OWNER_NAV_ITEMS: BottomNavItem[] = [
+ { href: "/owner", label: "Dashboard", icon: "chart-line" },
+ { href: "/owner/calendar", label: "Calendar", icon: "calendar-days" },
+ { href: "/owner/team", label: "Team", icon: "users" },
+ { href: "/owner/services", label: "Services", icon: "scissors" },
+ { href: "/owner/settings", label: "Settings", icon: "settings" },
+];
diff --git a/src/services/business/__tests__/business.service.integration.test.ts b/src/services/business/__tests__/business.service.integration.test.ts
new file mode 100644
index 0000000..8e1ce58
--- /dev/null
+++ b/src/services/business/__tests__/business.service.integration.test.ts
@@ -0,0 +1,153 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { Weekday } from "@/generated/prisma/enums";
+import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
+import type { CalendarDate } from "@/lib/time/business-time";
+import { getOpeningHours, setOpeningHours, updateBusinessProfile } from "../business.service";
+import {
+ cleanupTestBusiness,
+ createTestBusiness,
+ createTestCustomer,
+ createTestStaff,
+ prisma,
+} from "@/services/scheduling/__tests__/fixtures";
+import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
+
+function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
+ let date = addDays(todayIn(timezone), minDaysAhead);
+ for (let i = 0; i < 8; i++) {
+ if (weekdayOf(date) === target) return date;
+ date = addDays(date, 1);
+ }
+ throw new Error("unreachable");
+}
+
+describe("BusinessService (integration)", () => {
+ let business: TestBusiness;
+
+ beforeEach(async () => {
+ business = await createTestBusiness();
+ });
+
+ afterEach(async () => {
+ await cleanupTestBusiness(business);
+ });
+
+ describe("updateBusinessProfile", () => {
+ it("writes through to the Business and its primary BusinessLocation", async () => {
+ const result = await updateBusinessProfile(prisma, {
+ businessId: business.businessId,
+ profile: {
+ name: "Renamed Barbershop",
+ timezone: business.timezone,
+ currency: "GBP",
+ locale: "en-GB",
+ },
+ location: { addressLine1: "1 New Street", city: "Testville", postalCode: "TE1 1ST", country: "GB" },
+ });
+ expect(result.success).toBe(true);
+
+ const updated = await prisma.business.findUniqueOrThrow({
+ where: { id: business.businessId },
+ include: { locations: true },
+ });
+ expect(updated.name).toBe("Renamed Barbershop");
+ expect(updated.locations[0]?.city).toBe("Testville");
+ });
+
+ it("rejects an empty business name without writing anything", async () => {
+ const result = await updateBusinessProfile(prisma, {
+ businessId: business.businessId,
+ profile: { name: " ", timezone: business.timezone, currency: "GBP", locale: "en-GB" },
+ location: { addressLine1: "1 New Street", city: "Testville", postalCode: "TE1 1ST", country: "GB" },
+ });
+ expect(result.success).toBe(false);
+ expect(result.validation.issues[0]?.code).toBe("INVALID_INPUT");
+
+ const unchanged = await prisma.business.findUniqueOrThrow({ where: { id: business.businessId } });
+ expect(unchanged.name).toBe("Test Barbershop");
+ });
+ });
+
+ describe("setOpeningHours", () => {
+ it("blocks closing a day that has a future confirmed appointment, and Save Anyway bypasses it", async () => {
+ const { staffId } = await createTestStaff(business, { workingDays: [Weekday.MONDAY] });
+ const { customerId } = await createTestCustomer(business);
+ const monday = nextWeekday(business.timezone, Weekday.MONDAY);
+
+ const appointment = await prisma.appointment.create({
+ data: {
+ businessId: business.businessId,
+ staffId,
+ customerId,
+ status: "CONFIRMED",
+ startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
+ endAt: wallClockToInstant(monday, 10 * 60 + 30, business.timezone),
+ services: {
+ create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
+ },
+ },
+ });
+
+ const days = [
+ { dayOfWeek: Weekday.MONDAY, isClosed: true },
+ { dayOfWeek: Weekday.TUESDAY, isClosed: false, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
+ ];
+
+ const blocked = await setOpeningHours(prisma, {
+ businessId: business.businessId,
+ businessTimezone: business.timezone,
+ days,
+ });
+ expect(blocked.success).toBe(false);
+ if (blocked.success) throw new Error("unreachable");
+ expect(blocked.conflicts.map((c) => c.id)).toContain(appointment.id);
+
+ // The appointment is untouched — never auto-cancelled by tightening hours.
+ const stillConfirmed = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
+ expect(stillConfirmed.status).toBe("CONFIRMED");
+
+ const acknowledged = await setOpeningHours(prisma, {
+ businessId: business.businessId,
+ businessTimezone: business.timezone,
+ days,
+ acknowledgeConflicts: true,
+ });
+ expect(acknowledged.success).toBe(true);
+
+ const hours = await getOpeningHours(prisma, business.businessId);
+ const mondayHours = hours.find((h) => h.dayOfWeek === Weekday.MONDAY);
+ expect(mondayHours?.isClosed).toBe(true);
+
+ // The appointment still exists, unmodified, after the "Save Anyway".
+ const afterSave = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
+ expect(afterSave.status).toBe("CONFIRMED");
+ });
+
+ it("does not flag a conflict for an appointment that falls inside the new hours", async () => {
+ const { staffId } = await createTestStaff(business, { workingDays: [Weekday.MONDAY] });
+ const { customerId } = await createTestCustomer(business);
+ const monday = nextWeekday(business.timezone, Weekday.MONDAY);
+
+ await prisma.appointment.create({
+ data: {
+ businessId: business.businessId,
+ staffId,
+ customerId,
+ status: "CONFIRMED",
+ startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
+ endAt: wallClockToInstant(monday, 10 * 60 + 30, business.timezone),
+ services: {
+ create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
+ },
+ },
+ });
+
+ const result = await setOpeningHours(prisma, {
+ businessId: business.businessId,
+ businessTimezone: business.timezone,
+ days: [{ dayOfWeek: Weekday.MONDAY, isClosed: false, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 }],
+ });
+ expect(result.success).toBe(true);
+ });
+ });
+});
diff --git a/src/services/business/__tests__/business.service.test.ts b/src/services/business/__tests__/business.service.test.ts
new file mode 100644
index 0000000..1a0a24d
--- /dev/null
+++ b/src/services/business/__tests__/business.service.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest";
+import { Weekday } from "@/generated/prisma/enums";
+import { validateBusinessProfileInput, validateOpeningHoursInput } from "../business.service";
+
+describe("validateBusinessProfileInput", () => {
+ const base = {
+ name: "Blake Heritage Barbershop",
+ timezone: "Europe/London",
+ currency: "GBP",
+ locale: "en-GB",
+ };
+
+ it("accepts a complete profile", () => {
+ expect(validateBusinessProfileInput(base).valid).toBe(true);
+ });
+
+ it("rejects an empty name", () => {
+ const result = validateBusinessProfileInput({ ...base, name: " " });
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("INVALID_INPUT");
+ });
+
+ it("rejects a missing timezone/currency/locale", () => {
+ const result = validateBusinessProfileInput({ ...base, timezone: "" });
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("INVALID_INPUT");
+ });
+});
+
+describe("validateOpeningHoursInput", () => {
+ it("accepts a day marked closed with no times", () => {
+ const result = validateOpeningHoursInput([{ dayOfWeek: Weekday.SUNDAY, isClosed: true }]);
+ expect(result.valid).toBe(true);
+ });
+
+ it("requires open/close times for an open day", () => {
+ const result = validateOpeningHoursInput([{ dayOfWeek: Weekday.MONDAY, isClosed: false }]);
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("INVALID_INPUT");
+ });
+
+ it("rejects opening time at or after closing time", () => {
+ const result = validateOpeningHoursInput([
+ { dayOfWeek: Weekday.TUESDAY, isClosed: false, opensAtMinutes: 600, closesAtMinutes: 600 },
+ ]);
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
+ });
+
+ it("rejects times outside a single day", () => {
+ const result = validateOpeningHoursInput([
+ { dayOfWeek: Weekday.WEDNESDAY, isClosed: false, opensAtMinutes: 0, closesAtMinutes: 25 * 60 },
+ ]);
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
+ });
+
+ it("rejects a negative opening time", () => {
+ const result = validateOpeningHoursInput([
+ { dayOfWeek: Weekday.THURSDAY, isClosed: false, opensAtMinutes: -30, closesAtMinutes: 600 },
+ ]);
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("INVALID_TIME_RANGE");
+ });
+
+ it("accepts a valid full week mixing open and closed days", () => {
+ const result = validateOpeningHoursInput([
+ { dayOfWeek: Weekday.MONDAY, isClosed: false, opensAtMinutes: 10 * 60, closesAtMinutes: 19 * 60 },
+ { dayOfWeek: Weekday.SUNDAY, isClosed: true },
+ ]);
+ expect(result.valid).toBe(true);
+ });
+});
diff --git a/src/services/business/business.service.ts b/src/services/business/business.service.ts
new file mode 100644
index 0000000..3897ca4
--- /dev/null
+++ b/src/services/business/business.service.ts
@@ -0,0 +1,259 @@
+import type { PrismaClient } from "@/generated/prisma/client";
+import { Weekday } from "@/generated/prisma/enums";
+import { calendarDateOf, minutesFromMidnight, weekdayOf } from "@/lib/time/business-time";
+import { fail, ok } from "@/lib/validation-result";
+import type { ValidationResult } from "@/lib/validation-result";
+
+/**
+ * The business's own profile, address, policies, and opening hours —
+ * owner-only configuration that sits above everything StaffService and
+ * BookingRule already cover. Kept separate from BookingRule (scheduling
+ * policy) and LoyaltyService (loyalty) per the brief's own service
+ * boundaries; this file only knows about Business/BusinessLocation/
+ * BusinessSettings/BusinessHours.
+ */
+
+export type BusinessServiceErrorCode =
+ | "INVALID_INPUT"
+ | "INVALID_TIME_RANGE"
+ | "APPOINTMENT_CONFLICTS";
+
+export interface BusinessProfileInput {
+ name: string;
+ description?: string;
+ logoUrl?: string;
+ phone?: string;
+ email?: string;
+ website?: string;
+ instagramUrl?: string;
+ facebookUrl?: string;
+ timezone: string;
+ currency: string;
+ locale: string;
+}
+
+export interface BusinessLocationInput {
+ addressLine1: string;
+ addressLine2?: string;
+ city: string;
+ region?: string;
+ postalCode: string;
+ country: string;
+ latitude?: number;
+ longitude?: number;
+}
+
+export interface BusinessSettingsInput {
+ cancellationPolicy?: string;
+ lateArrivalPolicy?: string;
+ bookingTerms?: string;
+ privacyPolicyUrl?: string;
+}
+
+export function validateBusinessProfileInput(
+ input: BusinessProfileInput,
+): ValidationResult {
+ if (!input.name.trim()) {
+ return fail({ code: "INVALID_INPUT", message: "Business name is required." });
+ }
+ if (!input.timezone.trim() || !input.currency.trim() || !input.locale.trim()) {
+ return fail({ code: "INVALID_INPUT", message: "Timezone, currency, and locale are required." });
+ }
+ return ok();
+}
+
+export async function getBusinessProfile(prisma: PrismaClient, businessId: string) {
+ return prisma.business.findUnique({
+ where: { id: businessId },
+ include: {
+ locations: { where: { isPrimary: true }, take: 1 },
+ settings: true,
+ },
+ });
+}
+
+export async function updateBusinessProfile(
+ prisma: PrismaClient,
+ params: {
+ businessId: string;
+ profile: BusinessProfileInput;
+ location: BusinessLocationInput;
+ },
+): Promise<{ success: boolean; validation: ValidationResult }> {
+ const validation = validateBusinessProfileInput(params.profile);
+ if (!validation.valid) return { success: false, validation };
+ if (!params.location.addressLine1.trim() || !params.location.city.trim() || !params.location.postalCode.trim()) {
+ return {
+ success: false,
+ validation: fail({ code: "INVALID_INPUT", message: "Address line 1, city, and postcode are required." }),
+ };
+ }
+
+ const existingLocation = await prisma.businessLocation.findFirst({
+ where: { businessId: params.businessId, isPrimary: true },
+ });
+
+ await prisma.$transaction([
+ prisma.business.update({
+ where: { id: params.businessId },
+ data: { ...params.profile },
+ }),
+ existingLocation
+ ? prisma.businessLocation.update({
+ where: { id: existingLocation.id },
+ data: { ...params.location },
+ })
+ : prisma.businessLocation.create({
+ data: { businessId: params.businessId, isPrimary: true, ...params.location },
+ }),
+ ]);
+
+ return { success: true, validation: ok() };
+}
+
+export async function updateBusinessSettings(
+ prisma: PrismaClient,
+ params: { businessId: string; settings: BusinessSettingsInput },
+): Promise<{ success: boolean }> {
+ await prisma.businessSettings.upsert({
+ where: { businessId: params.businessId },
+ create: { businessId: params.businessId, ...params.settings },
+ update: { ...params.settings },
+ });
+ return { success: true };
+}
+
+// ── Opening hours ──────────────────────────────────────────────────────
+
+export interface OpeningHoursDayInput {
+ dayOfWeek: Weekday;
+ isClosed: boolean;
+ opensAtMinutes?: number;
+ closesAtMinutes?: number;
+}
+
+export function validateOpeningHoursInput(
+ days: OpeningHoursDayInput[],
+): ValidationResult {
+ for (const day of days) {
+ if (day.isClosed) continue;
+ if (day.opensAtMinutes === undefined || day.closesAtMinutes === undefined) {
+ return fail({
+ code: "INVALID_INPUT",
+ message: `${day.dayOfWeek}: opening and closing time are required for an open day.`,
+ });
+ }
+ if (day.opensAtMinutes < 0 || day.closesAtMinutes > 24 * 60) {
+ return fail({
+ code: "INVALID_TIME_RANGE",
+ message: `${day.dayOfWeek}: times must fall within a single day.`,
+ });
+ }
+ if (day.opensAtMinutes >= day.closesAtMinutes) {
+ return fail({
+ code: "INVALID_TIME_RANGE",
+ message: `${day.dayOfWeek}: opening time must be before closing time.`,
+ });
+ }
+ }
+ return ok();
+}
+
+export async function getOpeningHours(prisma: PrismaClient, businessId: string) {
+ return prisma.businessHours.findMany({ where: { businessId }, orderBy: { dayOfWeek: "asc" } });
+}
+
+const APPOINTMENT_CONFLICT_INCLUDE = {
+ services: { include: { service: true } },
+ customer: true,
+ staff: { include: { user: { select: { name: true } } } },
+} as const;
+
+/**
+ * Future, non-cancelled appointments that would fall outside the proposed
+ * hours — either on a day newly marked closed, or before/after the new
+ * open/close window on a day that stays open. Mirrors the Phase 5
+ * break/time-off conflict pattern: this never cancels anything itself, it
+ * only reports what the caller should show before asking for
+ * acknowledgement.
+ */
+export async function findOpeningHoursConflicts(
+ prisma: PrismaClient,
+ params: { businessId: string; businessTimezone: string; days: OpeningHoursDayInput[] },
+) {
+ const appointments = await prisma.appointment.findMany({
+ where: {
+ businessId: params.businessId,
+ status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
+ startAt: { gte: new Date() },
+ },
+ include: APPOINTMENT_CONFLICT_INCLUDE,
+ orderBy: { startAt: "asc" },
+ });
+
+ const byDay = new Map(params.days.map((d) => [d.dayOfWeek, d]));
+
+ return appointments.filter((a) => {
+ const weekday = weekdayOf(calendarDateOf(a.startAt, params.businessTimezone));
+ const proposed = byDay.get(weekday);
+ if (!proposed) return false;
+ if (proposed.isClosed) return true;
+
+ const aStart = minutesFromMidnight(a.startAt, params.businessTimezone);
+ const aEnd = minutesFromMidnight(a.endAt, params.businessTimezone);
+ return aStart < (proposed.opensAtMinutes ?? 0) || aEnd > (proposed.closesAtMinutes ?? 24 * 60);
+ });
+}
+
+export async function setOpeningHours(
+ prisma: PrismaClient,
+ params: {
+ businessId: string;
+ businessTimezone: string;
+ days: OpeningHoursDayInput[];
+ acknowledgeConflicts?: boolean;
+ },
+) {
+ const validation = validateOpeningHoursInput(params.days);
+ if (!validation.valid) return { success: false as const, validation, conflicts: [] };
+
+ if (!params.acknowledgeConflicts) {
+ const conflicts = await findOpeningHoursConflicts(prisma, {
+ businessId: params.businessId,
+ businessTimezone: params.businessTimezone,
+ days: params.days,
+ });
+ if (conflicts.length > 0) {
+ return {
+ success: false as const,
+ conflicts,
+ validation: fail({
+ code: "APPOINTMENT_CONFLICTS",
+ message: "These hours would leave existing appointments outside the business's open hours.",
+ }),
+ };
+ }
+ }
+
+ await prisma.$transaction(
+ params.days.map((day) =>
+ prisma.businessHours.upsert({
+ where: { businessId_dayOfWeek: { businessId: params.businessId, dayOfWeek: day.dayOfWeek } },
+ create: {
+ businessId: params.businessId,
+ dayOfWeek: day.dayOfWeek,
+ isClosed: day.isClosed,
+ opensAtMinutes: day.isClosed ? null : (day.opensAtMinutes ?? null),
+ closesAtMinutes: day.isClosed ? null : (day.closesAtMinutes ?? null),
+ },
+ update: {
+ isClosed: day.isClosed,
+ opensAtMinutes: day.isClosed ? null : (day.opensAtMinutes ?? null),
+ closesAtMinutes: day.isClosed ? null : (day.closesAtMinutes ?? null),
+ },
+ }),
+ ),
+ );
+
+ return { success: true as const, conflicts: [], validation: ok() };
+}
diff --git a/src/services/catalogue/__tests__/service-catalogue.service.integration.test.ts b/src/services/catalogue/__tests__/service-catalogue.service.integration.test.ts
new file mode 100644
index 0000000..42d6f32
--- /dev/null
+++ b/src/services/catalogue/__tests__/service-catalogue.service.integration.test.ts
@@ -0,0 +1,153 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+ createCategory,
+ deleteCategory,
+ findServiceUsageConflicts,
+ updateService,
+} from "../service-catalogue.service";
+import {
+ cleanupTestBusiness,
+ createTestBusiness,
+ createTestCustomer,
+ createTestStaff,
+ prisma,
+} from "@/services/scheduling/__tests__/fixtures";
+import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
+
+describe("ServiceCatalogueService (integration)", () => {
+ let business: TestBusiness;
+
+ beforeEach(async () => {
+ business = await createTestBusiness();
+ });
+
+ afterEach(async () => {
+ await cleanupTestBusiness(business);
+ });
+
+ describe("categories", () => {
+ it("blocks deleting a category that still has services", async () => {
+ // business.categoryId already owns the fixture haircut/beard-trim services.
+ const result = await deleteCategory(prisma, { categoryId: business.categoryId, businessId: business.businessId });
+ expect(result.success).toBe(false);
+ if (result.success) throw new Error("unreachable");
+ expect(result.validation.issues[0]?.code).toBe("CATEGORY_HAS_SERVICES");
+
+ const stillExists = await prisma.serviceCategory.findUnique({ where: { id: business.categoryId } });
+ expect(stillExists).not.toBeNull();
+ });
+
+ it("allows deleting an empty category", async () => {
+ const created = await createCategory(prisma, { businessId: business.businessId, input: { name: "Empty" } });
+ if (!created.success) throw new Error("setup failed");
+
+ const result = await deleteCategory(prisma, { categoryId: created.category.id, businessId: business.businessId });
+ expect(result.success).toBe(true);
+
+ const gone = await prisma.serviceCategory.findUnique({ where: { id: created.category.id } });
+ expect(gone).toBeNull();
+ });
+
+ it("scopes deletion to the given business — a category from a different business is not found", async () => {
+ const otherBusiness = await createTestBusiness();
+ try {
+ const result = await deleteCategory(prisma, {
+ categoryId: otherBusiness.categoryId,
+ businessId: business.businessId,
+ });
+ expect(result.success).toBe(false);
+ if (result.success) throw new Error("unreachable");
+ expect(result.validation.issues[0]?.code).toBe("CATEGORY_NOT_FOUND");
+ } finally {
+ await cleanupTestBusiness(otherBusiness);
+ }
+ });
+ });
+
+ describe("services", () => {
+ it("editing a service's price never rewrites a past appointment's priceAtBooking snapshot", async () => {
+ const { staffId } = await createTestStaff(business);
+ const { customerId } = await createTestCustomer(business);
+
+ const pastAppointment = await prisma.appointment.create({
+ data: {
+ businessId: business.businessId,
+ staffId,
+ customerId,
+ status: "COMPLETED",
+ startAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
+ endAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000 + 30 * 60_000),
+ services: {
+ create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
+ },
+ },
+ include: { services: true },
+ });
+
+ const updated = await updateService(prisma, {
+ serviceId: business.haircutServiceId,
+ businessId: business.businessId,
+ input: {
+ categoryId: business.categoryId,
+ name: "Haircut",
+ durationMinutes: 45,
+ price: "35.00",
+ bufferBeforeMinutes: 0,
+ bufferAfterMinutes: 0,
+ allowOnlineBooking: true,
+ },
+ });
+ expect(updated.success).toBe(true);
+
+ const snapshot = await prisma.appointmentService.findUniqueOrThrow({
+ where: { id: pastAppointment.services[0]!.id },
+ });
+ expect(Number(snapshot.priceAtBooking)).toBe(20);
+ expect(snapshot.durationAtBookingMinutes).toBe(30);
+
+ const liveService = await prisma.service.findUniqueOrThrow({ where: { id: business.haircutServiceId } });
+ expect(Number(liveService.price)).toBe(35);
+ expect(liveService.durationMinutes).toBe(45);
+ });
+
+ it("findServiceUsageConflicts finds only future, non-cancelled appointments booking that service", async () => {
+ const { staffId } = await createTestStaff(business);
+ const { customerId } = await createTestCustomer(business);
+
+ const future = await prisma.appointment.create({
+ data: {
+ businessId: business.businessId,
+ staffId,
+ customerId,
+ status: "CONFIRMED",
+ startAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
+ endAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000 + 30 * 60_000),
+ services: {
+ create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
+ },
+ },
+ });
+
+ // Cancelled — must not appear in conflicts.
+ await prisma.appointment.create({
+ data: {
+ businessId: business.businessId,
+ staffId,
+ customerId,
+ status: "CANCELLED",
+ startAt: new Date(Date.now() + 8 * 24 * 60 * 60 * 1000),
+ endAt: new Date(Date.now() + 8 * 24 * 60 * 60 * 1000 + 30 * 60_000),
+ services: {
+ create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
+ },
+ },
+ });
+
+ const conflicts = await findServiceUsageConflicts(prisma, {
+ serviceId: business.haircutServiceId,
+ businessId: business.businessId,
+ });
+ expect(conflicts.map((c) => c.id)).toEqual([future.id]);
+ });
+ });
+});
diff --git a/src/services/catalogue/__tests__/service-catalogue.service.test.ts b/src/services/catalogue/__tests__/service-catalogue.service.test.ts
new file mode 100644
index 0000000..9127612
--- /dev/null
+++ b/src/services/catalogue/__tests__/service-catalogue.service.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from "vitest";
+import { validateCategoryInput, validateServiceInput } from "../service-catalogue.service";
+
+describe("validateCategoryInput", () => {
+ it("accepts a valid category name", () => {
+ expect(validateCategoryInput({ name: "Beard Services" }).valid).toBe(true);
+ });
+
+ it("rejects an empty/whitespace-only name", () => {
+ const result = validateCategoryInput({ name: " " });
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("INVALID_INPUT");
+ });
+});
+
+describe("validateServiceInput", () => {
+ const base = {
+ categoryId: "cat-1",
+ name: "Haircut & Style",
+ durationMinutes: 30,
+ price: "22.50",
+ bufferBeforeMinutes: 0,
+ bufferAfterMinutes: 5,
+ allowOnlineBooking: true,
+ };
+
+ it("accepts a fully valid service", () => {
+ expect(validateServiceInput(base).valid).toBe(true);
+ });
+
+ it("rejects an empty name", () => {
+ const result = validateServiceInput({ ...base, name: "" });
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("INVALID_INPUT");
+ });
+
+ it("rejects a missing category", () => {
+ const result = validateServiceInput({ ...base, categoryId: "" });
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("INVALID_INPUT");
+ });
+
+ it("rejects a zero or negative duration", () => {
+ const result = validateServiceInput({ ...base, durationMinutes: 0 });
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("INVALID_DURATION");
+ });
+
+ it("rejects a non-integer duration", () => {
+ const result = validateServiceInput({ ...base, durationMinutes: 30.5 });
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("INVALID_DURATION");
+ });
+
+ it.each(["free", "-5.00", "5.999", "5,00", ""])("rejects an invalid price %s", (price) => {
+ const result = validateServiceInput({ ...base, price });
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("INVALID_PRICE");
+ });
+
+ it.each(["0", "22", "22.5", "22.50"])("accepts a valid decimal price %s", (price) => {
+ expect(validateServiceInput({ ...base, price }).valid).toBe(true);
+ });
+
+ it("rejects a negative buffer", () => {
+ const result = validateServiceInput({ ...base, bufferBeforeMinutes: -1 });
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("INVALID_INPUT");
+ });
+});
diff --git a/src/services/catalogue/service-catalogue.service.ts b/src/services/catalogue/service-catalogue.service.ts
new file mode 100644
index 0000000..649748e
--- /dev/null
+++ b/src/services/catalogue/service-catalogue.service.ts
@@ -0,0 +1,322 @@
+import type { PrismaClient } from "@/generated/prisma/client";
+import { fail, ok } from "@/lib/validation-result";
+import type { ValidationResult } from "@/lib/validation-result";
+
+/**
+ * Service categories and services — the owner-facing catalogue. Staff
+ * eligibility (which barber can perform which service) stays in
+ * StaffService (see docs/phase-6-summary.md, "Domain services") since it's
+ * staff-scoped data, not catalogue data; this file only owns
+ * ServiceCategory/Service themselves.
+ *
+ * Money is handled as strings end to end — a price never passes through a
+ * JS `number` on its way into Prisma's `Decimal` column, so there's no
+ * floating-point rounding step to get wrong.
+ */
+
+export type ServiceCatalogueErrorCode =
+ | "INVALID_INPUT"
+ | "INVALID_PRICE"
+ | "INVALID_DURATION"
+ | "CATEGORY_NOT_FOUND"
+ | "CATEGORY_HAS_SERVICES"
+ | "SERVICE_NOT_FOUND"
+ | "APPOINTMENT_CONFLICTS";
+
+const DECIMAL_PRICE = /^\d+(\.\d{1,2})?$/;
+
+// ── Categories ──────────────────────────────────────────────────────────
+
+export interface CategoryInput {
+ name: string;
+ description?: string;
+}
+
+export function validateCategoryInput(input: CategoryInput): ValidationResult {
+ if (!input.name.trim()) return fail({ code: "INVALID_INPUT", message: "Category name is required." });
+ return ok();
+}
+
+export async function listCategories(prisma: PrismaClient, businessId: string) {
+ return prisma.serviceCategory.findMany({
+ where: { businessId },
+ orderBy: { displayOrder: "asc" },
+ include: { services: { orderBy: { displayOrder: "asc" } } },
+ });
+}
+
+export async function createCategory(
+ prisma: PrismaClient,
+ params: { businessId: string; input: CategoryInput },
+) {
+ const validation = validateCategoryInput(params.input);
+ if (!validation.valid) return { success: false as const, validation };
+
+ const maxOrder = await prisma.serviceCategory.aggregate({
+ where: { businessId: params.businessId },
+ _max: { displayOrder: true },
+ });
+
+ const category = await prisma.serviceCategory.create({
+ data: {
+ businessId: params.businessId,
+ name: params.input.name.trim(),
+ displayOrder: (maxOrder._max.displayOrder ?? -1) + 1,
+ },
+ });
+
+ return { success: true as const, category, validation: ok() };
+}
+
+export async function updateCategory(
+ prisma: PrismaClient,
+ params: { categoryId: string; businessId: string; input: CategoryInput },
+) {
+ const validation = validateCategoryInput(params.input);
+ if (!validation.valid) return { success: false as const, validation };
+
+ const existing = await prisma.serviceCategory.findFirst({
+ where: { id: params.categoryId, businessId: params.businessId },
+ });
+ if (!existing) {
+ return {
+ success: false as const,
+ validation: fail({ code: "CATEGORY_NOT_FOUND", message: "Category not found." }),
+ };
+ }
+
+ await prisma.serviceCategory.update({
+ where: { id: params.categoryId },
+ data: { name: params.input.name.trim() },
+ });
+ return { success: true as const, validation: ok() };
+}
+
+export async function setCategoryActive(
+ prisma: PrismaClient,
+ params: { categoryId: string; businessId: string; isActive: boolean },
+): Promise<{ success: boolean }> {
+ const result = await prisma.serviceCategory.updateMany({
+ where: { id: params.categoryId, businessId: params.businessId },
+ data: { isActive: params.isActive },
+ });
+ return { success: result.count > 0 };
+}
+
+export async function reorderCategories(
+ prisma: PrismaClient,
+ params: { businessId: string; orderedCategoryIds: string[] },
+): Promise<{ success: boolean }> {
+ await prisma.$transaction(
+ params.orderedCategoryIds.map((id, index) =>
+ prisma.serviceCategory.updateMany({
+ where: { id, businessId: params.businessId },
+ data: { displayOrder: index },
+ }),
+ ),
+ );
+ return { success: true };
+}
+
+/** Categories with services (active or not) can't be deleted outright — deactivate instead, per the brief's "prefer deactivation over destructive deletion where historical data depends on it." */
+export async function deleteCategory(
+ prisma: PrismaClient,
+ params: { categoryId: string; businessId: string },
+) {
+ const existing = await prisma.serviceCategory.findFirst({
+ where: { id: params.categoryId, businessId: params.businessId },
+ include: { _count: { select: { services: true } } },
+ });
+ if (!existing) {
+ return {
+ success: false as const,
+ validation: fail({ code: "CATEGORY_NOT_FOUND", message: "Category not found." }),
+ };
+ }
+ if (existing._count.services > 0) {
+ return {
+ success: false as const,
+ validation: fail({
+ code: "CATEGORY_HAS_SERVICES",
+ message: "This category still has services — move or remove them first, or deactivate the category instead.",
+ }),
+ };
+ }
+
+ await prisma.serviceCategory.delete({ where: { id: params.categoryId } });
+ return { success: true as const, validation: ok() };
+}
+
+// ── Services ────────────────────────────────────────────────────────────
+
+export interface ServiceInput {
+ categoryId: string;
+ name: string;
+ description?: string;
+ durationMinutes: number;
+ /** Decimal string, e.g. "22.50" — never a JS number, see file header. */
+ price: string;
+ bufferBeforeMinutes: number;
+ bufferAfterMinutes: number;
+ allowOnlineBooking: boolean;
+}
+
+export function validateServiceInput(input: ServiceInput): ValidationResult {
+ if (!input.name.trim()) return fail({ code: "INVALID_INPUT", message: "Service name is required." });
+ if (!input.categoryId) return fail({ code: "INVALID_INPUT", message: "Choose a category." });
+ if (!Number.isInteger(input.durationMinutes) || input.durationMinutes <= 0) {
+ return fail({ code: "INVALID_DURATION", message: "Duration must be a positive number of minutes." });
+ }
+ if (!DECIMAL_PRICE.test(input.price)) {
+ return fail({ code: "INVALID_PRICE", message: "Enter a price like 22.50, with at most two decimal places." });
+ }
+ if (
+ !Number.isInteger(input.bufferBeforeMinutes) ||
+ !Number.isInteger(input.bufferAfterMinutes) ||
+ input.bufferBeforeMinutes < 0 ||
+ input.bufferAfterMinutes < 0
+ ) {
+ return fail({ code: "INVALID_INPUT", message: "Buffers must be zero or a positive number of minutes." });
+ }
+ return ok();
+}
+
+export async function listServices(
+ prisma: PrismaClient,
+ params: { businessId: string; categoryId?: string; q?: string },
+) {
+ return prisma.service.findMany({
+ where: {
+ businessId: params.businessId,
+ ...(params.categoryId ? { categoryId: params.categoryId } : {}),
+ ...(params.q ? { name: { contains: params.q, mode: "insensitive" } } : {}),
+ },
+ include: { category: true },
+ orderBy: [{ categoryId: "asc" }, { displayOrder: "asc" }],
+ });
+}
+
+export async function createService(
+ prisma: PrismaClient,
+ params: { businessId: string; input: ServiceInput },
+) {
+ const validation = validateServiceInput(params.input);
+ if (!validation.valid) return { success: false as const, validation };
+
+ const category = await prisma.serviceCategory.findFirst({
+ where: { id: params.input.categoryId, businessId: params.businessId },
+ });
+ if (!category) {
+ return {
+ success: false as const,
+ validation: fail({ code: "CATEGORY_NOT_FOUND", message: "Category not found." }),
+ };
+ }
+
+ const maxOrder = await prisma.service.aggregate({
+ where: { businessId: params.businessId, categoryId: params.input.categoryId },
+ _max: { displayOrder: true },
+ });
+
+ const service = await prisma.service.create({
+ data: {
+ businessId: params.businessId,
+ categoryId: params.input.categoryId,
+ name: params.input.name.trim(),
+ description: params.input.description?.trim() || null,
+ durationMinutes: params.input.durationMinutes,
+ price: params.input.price,
+ bufferBeforeMinutes: params.input.bufferBeforeMinutes,
+ bufferAfterMinutes: params.input.bufferAfterMinutes,
+ allowOnlineBooking: params.input.allowOnlineBooking,
+ displayOrder: (maxOrder._max.displayOrder ?? -1) + 1,
+ },
+ });
+
+ return { success: true as const, service, validation: ok() };
+}
+
+const APPOINTMENT_CONFLICT_INCLUDE = {
+ customer: true,
+ staff: { include: { user: { select: { name: true } } } },
+} as const;
+
+/** Future, non-cancelled appointments that booked this exact service — informational for an edit/deactivation confirmation, never auto-resolved. Historical AppointmentService rows (priceAtBooking/durationAtBookingMinutes) are untouched regardless. */
+export async function findServiceUsageConflicts(
+ prisma: PrismaClient,
+ params: { serviceId: string; businessId: string },
+) {
+ return prisma.appointment.findMany({
+ where: {
+ businessId: params.businessId,
+ status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
+ startAt: { gte: new Date() },
+ services: { some: { serviceId: params.serviceId } },
+ },
+ include: APPOINTMENT_CONFLICT_INCLUDE,
+ orderBy: { startAt: "asc" },
+ });
+}
+
+export async function updateService(
+ prisma: PrismaClient,
+ params: { serviceId: string; businessId: string; input: ServiceInput },
+) {
+ const validation = validateServiceInput(params.input);
+ if (!validation.valid) return { success: false as const, validation };
+
+ const existing = await prisma.service.findFirst({
+ where: { id: params.serviceId, businessId: params.businessId },
+ });
+ if (!existing) {
+ return {
+ success: false as const,
+ validation: fail({ code: "SERVICE_NOT_FOUND", message: "Service not found." }),
+ };
+ }
+
+ // Price/duration snapshots on past AppointmentService rows are never
+ // rewritten — this only ever changes the live Service row future
+ // bookings will read at booking time. See docs/phase-6-summary.md.
+ await prisma.service.update({
+ where: { id: params.serviceId },
+ data: {
+ categoryId: params.input.categoryId,
+ name: params.input.name.trim(),
+ description: params.input.description?.trim() || null,
+ durationMinutes: params.input.durationMinutes,
+ price: params.input.price,
+ bufferBeforeMinutes: params.input.bufferBeforeMinutes,
+ bufferAfterMinutes: params.input.bufferAfterMinutes,
+ allowOnlineBooking: params.input.allowOnlineBooking,
+ },
+ });
+
+ return { success: true as const, validation: ok() };
+}
+
+export async function setServiceActive(
+ prisma: PrismaClient,
+ params: { serviceId: string; businessId: string; isActive: boolean },
+): Promise<{ success: boolean }> {
+ const result = await prisma.service.updateMany({
+ where: { id: params.serviceId, businessId: params.businessId },
+ data: { isActive: params.isActive },
+ });
+ return { success: result.count > 0 };
+}
+
+export async function reorderServices(
+ prisma: PrismaClient,
+ params: { businessId: string; categoryId: string; orderedServiceIds: string[] },
+): Promise<{ success: boolean }> {
+ await prisma.$transaction(
+ params.orderedServiceIds.map((id, index) =>
+ prisma.service.updateMany({
+ where: { id, businessId: params.businessId, categoryId: params.categoryId },
+ data: { displayOrder: index },
+ }),
+ ),
+ );
+ return { success: true };
+}
diff --git a/src/services/loyalty/__tests__/loyalty-config.test.ts b/src/services/loyalty/__tests__/loyalty-config.test.ts
new file mode 100644
index 0000000..f911999
--- /dev/null
+++ b/src/services/loyalty/__tests__/loyalty-config.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from "vitest";
+import { validateProgrammeConfig } from "../loyalty.service";
+import type { ProgrammeConfigInput } from "../loyalty.service";
+
+const base: ProgrammeConfigInput = {
+ isEnabled: true,
+ stampsRequired: 10,
+ rewardType: "PERCENTAGE",
+ rewardValue: "50",
+ autoApplyReward: true,
+ requiresStaffApproval: false,
+ earnOnCancelled: false,
+ earnOnNoShow: false,
+};
+
+describe("validateProgrammeConfig", () => {
+ it("accepts a valid percentage-reward configuration", () => {
+ expect(validateProgrammeConfig(base).valid).toBe(true);
+ });
+
+ it("rejects a stamps-required of zero", () => {
+ const result = validateProgrammeConfig({ ...base, stampsRequired: 0 });
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("INVALID_INPUT");
+ });
+
+ it("allows an incomplete configuration while disabled", () => {
+ const result = validateProgrammeConfig({ ...base, isEnabled: false, rewardValue: undefined });
+ expect(result.valid).toBe(true);
+ });
+
+ it.each([0, 101, -5])("rejects an out-of-range percentage when enabled (%s)", (pct) => {
+ const result = validateProgrammeConfig({ ...base, rewardValue: String(pct) });
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("INVALID_PERCENTAGE");
+ });
+
+ it("rejects a missing percentage value when enabled", () => {
+ const result = validateProgrammeConfig({ ...base, rewardValue: undefined });
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("INVALID_PERCENTAGE");
+ });
+
+ it("accepts a valid fixed-amount reward", () => {
+ const result = validateProgrammeConfig({ ...base, rewardType: "FIXED_AMOUNT", rewardValue: "10.00" });
+ expect(result.valid).toBe(true);
+ });
+
+ it("rejects a negative fixed-amount reward", () => {
+ const result = validateProgrammeConfig({ ...base, rewardType: "FIXED_AMOUNT", rewardValue: "-1" });
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("INVALID_VALUE");
+ });
+
+ it("accepts a free-service reward with a service chosen", () => {
+ const result = validateProgrammeConfig({ ...base, rewardType: "FREE_SERVICE", freeServiceId: "svc-1" });
+ expect(result.valid).toBe(true);
+ });
+
+ it("rejects a free-service reward with no service chosen", () => {
+ const result = validateProgrammeConfig({ ...base, rewardType: "FREE_SERVICE", freeServiceId: undefined });
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("FREE_SERVICE_REQUIRED");
+ });
+});
diff --git a/src/services/loyalty/__tests__/loyalty-owner.integration.test.ts b/src/services/loyalty/__tests__/loyalty-owner.integration.test.ts
new file mode 100644
index 0000000..07657da
--- /dev/null
+++ b/src/services/loyalty/__tests__/loyalty-owner.integration.test.ts
@@ -0,0 +1,248 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+ redeemReward,
+ setEligibleServices,
+ updateProgrammeConfig,
+} from "../loyalty.service";
+import {
+ cleanupTestBusiness,
+ createTestBusiness,
+ createTestCustomer,
+ createTestStaff,
+ prisma,
+} from "@/services/scheduling/__tests__/fixtures";
+import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
+
+describe("LoyaltyService — owner configuration and redemption (integration)", () => {
+ let business: TestBusiness;
+
+ beforeEach(async () => {
+ business = await createTestBusiness();
+ });
+
+ afterEach(async () => {
+ await cleanupTestBusiness(business);
+ });
+
+ describe("updateProgrammeConfig", () => {
+ it("creates the LoyaltyProgramme row on first save and updates it on the next", async () => {
+ const first = await updateProgrammeConfig(prisma, {
+ businessId: business.businessId,
+ input: {
+ isEnabled: true,
+ stampsRequired: 8,
+ rewardType: "PERCENTAGE",
+ rewardValue: "25",
+ autoApplyReward: true,
+ requiresStaffApproval: false,
+ earnOnCancelled: false,
+ earnOnNoShow: false,
+ },
+ });
+ expect(first.success).toBe(true);
+
+ const programme = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: business.businessId } });
+ expect(programme.stampsRequired).toBe(8);
+ expect(Number(programme.rewardValue)).toBe(25);
+
+ const second = await updateProgrammeConfig(prisma, {
+ businessId: business.businessId,
+ input: {
+ isEnabled: true,
+ stampsRequired: 12,
+ rewardType: "PERCENTAGE",
+ rewardValue: "40",
+ autoApplyReward: true,
+ requiresStaffApproval: false,
+ earnOnCancelled: false,
+ earnOnNoShow: false,
+ },
+ });
+ expect(second.success).toBe(true);
+
+ const updated = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: business.businessId } });
+ expect(updated.stampsRequired).toBe(12);
+ expect(Number(updated.rewardValue)).toBe(40);
+ });
+
+ it("rejects enabling a FREE_SERVICE reward pointing at an inactive service", async () => {
+ const inactiveService = await prisma.service.update({
+ where: { id: business.beardTrimServiceId },
+ data: { isActive: false },
+ });
+
+ const result = await updateProgrammeConfig(prisma, {
+ businessId: business.businessId,
+ input: {
+ isEnabled: true,
+ stampsRequired: 10,
+ rewardType: "FREE_SERVICE",
+ freeServiceId: inactiveService.id,
+ autoApplyReward: true,
+ requiresStaffApproval: false,
+ earnOnCancelled: false,
+ earnOnNoShow: false,
+ },
+ });
+ expect(result.success).toBe(false);
+ expect(result.validation.issues[0]?.code).toBe("FREE_SERVICE_REQUIRED");
+ });
+ });
+
+ describe("setEligibleServices", () => {
+ it("replaces the eligible-services list wholesale", async () => {
+ const programme = await prisma.loyaltyProgramme.create({
+ data: { businessId: business.businessId, stampsRequired: 10, rewardType: "PERCENTAGE", rewardValue: 50 },
+ });
+
+ await setEligibleServices(prisma, { loyaltyProgrammeId: programme.id, serviceIds: [business.haircutServiceId] });
+ let rows = await prisma.loyaltyEligibleService.findMany({ where: { loyaltyProgrammeId: programme.id } });
+ expect(rows.map((r) => r.serviceId)).toEqual([business.haircutServiceId]);
+
+ await setEligibleServices(prisma, { loyaltyProgrammeId: programme.id, serviceIds: [business.beardTrimServiceId] });
+ rows = await prisma.loyaltyEligibleService.findMany({ where: { loyaltyProgrammeId: programme.id } });
+ expect(rows.map((r) => r.serviceId)).toEqual([business.beardTrimServiceId]);
+ });
+ });
+
+ describe("redeemReward", () => {
+ async function setUpRedeemableReward() {
+ const programme = await prisma.loyaltyProgramme.create({
+ data: { businessId: business.businessId, stampsRequired: 10, rewardType: "PERCENTAGE", rewardValue: 50 },
+ });
+ const { staffId } = await createTestStaff(business);
+ const { customerId } = await createTestCustomer(business);
+ const account = await prisma.loyaltyAccount.create({
+ data: { customerId, loyaltyProgrammeId: programme.id, currentStamps: 10 },
+ });
+ const reward = await prisma.loyaltyReward.create({
+ data: { loyaltyAccountId: account.id, rewardType: "PERCENTAGE", value: 50, label: "50% Off", status: "AVAILABLE" },
+ });
+ const appointment = await prisma.appointment.create({
+ data: {
+ businessId: business.businessId,
+ staffId,
+ customerId,
+ status: "CONFIRMED",
+ startAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
+ endAt: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60_000),
+ services: {
+ create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
+ },
+ },
+ });
+ return { staffId, account, reward, appointment };
+ }
+
+ it("applies the discount, marks the reward REDEEMED, and deducts stamps — all atomically", async () => {
+ const { staffId, account, reward, appointment } = await setUpRedeemableReward();
+
+ const result = await redeemReward(prisma, {
+ rewardId: reward.id,
+ appointmentId: appointment.id,
+ staffId,
+ businessId: business.businessId,
+ });
+ expect(result.success).toBe(true);
+ expect(result.discountAmount).toBe("10.00"); // 50% of a 20.00 haircut
+
+ const updatedReward = await prisma.loyaltyReward.findUniqueOrThrow({ where: { id: reward.id } });
+ expect(updatedReward.status).toBe("REDEEMED");
+ expect(updatedReward.redeemedByStaffId).toBe(staffId);
+ expect(updatedReward.appliedToAppointmentId).toBe(appointment.id);
+
+ const updatedAccount = await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: account.id } });
+ expect(updatedAccount.currentStamps).toBe(0);
+
+ const updatedAppointment = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
+ expect(Number(updatedAppointment.discountAmount)).toBe(10);
+ });
+
+ it("prevents redeeming the same reward twice — the second attempt reports REWARD_NOT_AVAILABLE, not a double deduction", async () => {
+ const { staffId, account, reward, appointment } = await setUpRedeemableReward();
+
+ const first = await redeemReward(prisma, {
+ rewardId: reward.id,
+ appointmentId: appointment.id,
+ staffId,
+ businessId: business.businessId,
+ });
+ expect(first.success).toBe(true);
+
+ // A second appointment to apply to, since the first is now discounted.
+ const { customerId } = await createTestCustomer(business);
+ void customerId;
+ const secondAppointment = await prisma.appointment.create({
+ data: {
+ businessId: business.businessId,
+ staffId,
+ customerId: account.customerId,
+ status: "CONFIRMED",
+ startAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000),
+ endAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000 + 30 * 60_000),
+ services: {
+ create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
+ },
+ },
+ });
+
+ const second = await redeemReward(prisma, {
+ rewardId: reward.id,
+ appointmentId: secondAppointment.id,
+ staffId,
+ businessId: business.businessId,
+ });
+ expect(second.success).toBe(false);
+ expect(second.validation.issues[0]?.code).toBe("REWARD_NOT_AVAILABLE");
+
+ // Stamps weren't deducted a second time.
+ const account2 = await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: account.id } });
+ expect(account2.currentStamps).toBe(0);
+
+ // The second appointment was never discounted.
+ const unaffected = await prisma.appointment.findUniqueOrThrow({ where: { id: secondAppointment.id } });
+ expect(unaffected.discountAmount).toBeNull();
+ });
+
+ it("rejects applying a reward to an appointment that already has a discount", async () => {
+ const { staffId, reward, appointment } = await setUpRedeemableReward();
+ await prisma.appointment.update({ where: { id: appointment.id }, data: { discountAmount: 5 } });
+
+ const result = await redeemReward(prisma, {
+ rewardId: reward.id,
+ appointmentId: appointment.id,
+ staffId,
+ businessId: business.businessId,
+ });
+ expect(result.success).toBe(false);
+ expect(result.validation.issues[0]?.code).toBe("APPOINTMENT_NOT_ELIGIBLE");
+ });
+
+ it("rejects applying a reward to a different customer's appointment", async () => {
+ const { staffId, reward } = await setUpRedeemableReward();
+ const { customerId: otherCustomerId } = await createTestCustomer(business);
+ const otherAppointment = await prisma.appointment.create({
+ data: {
+ businessId: business.businessId,
+ staffId,
+ customerId: otherCustomerId,
+ status: "CONFIRMED",
+ startAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
+ endAt: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60_000),
+ services: {
+ create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
+ },
+ },
+ });
+
+ const result = await redeemReward(prisma, {
+ rewardId: reward.id,
+ appointmentId: otherAppointment.id,
+ staffId,
+ businessId: business.businessId,
+ });
+ expect(result.success).toBe(false);
+ expect(result.validation.issues[0]?.code).toBe("REWARD_CUSTOMER_MISMATCH");
+ });
+ });
+});
diff --git a/src/services/loyalty/loyalty.service.ts b/src/services/loyalty/loyalty.service.ts
index c4f1a27..9a88812 100644
--- a/src/services/loyalty/loyalty.service.ts
+++ b/src/services/loyalty/loyalty.service.ts
@@ -9,11 +9,11 @@ import type { ValidationResult } from "@/lib/validation-result";
* without creating the matching transaction in the same call, and nothing
* outside this file should touch either table directly.
*
- * Reward reset-on-redemption (see LoyaltyReward's own schema comment) is
- * deliberately not implemented here — redeeming a reward isn't one of the
- * Phase 5 barber capabilities, so `currentStamps` is allowed to sit at or
- * above `stampsRequired` once a reward becomes available. Redemption is a
- * documented Phase 5 limitation, not an oversight.
+ * Reward redemption (Phase 6) follows the same discipline: redeeming a
+ * reward, deducting the consumed stamps from the cached total, and
+ * recording the ledger entry all happen inside one `$transaction` — see
+ * `redeemReward`. This was a documented, deliberate Phase 5 limitation,
+ * not an oversight; it's implemented now.
*/
export type LoyaltyErrorCode =
@@ -24,7 +24,15 @@ export type LoyaltyErrorCode =
| "REASON_REQUIRED"
| "INVALID_AMOUNT"
| "APPOINTMENT_NOT_FOUND"
- | "APPOINTMENT_NOT_COMPLETED";
+ | "APPOINTMENT_NOT_COMPLETED"
+ | "INVALID_INPUT"
+ | "INVALID_PERCENTAGE"
+ | "INVALID_VALUE"
+ | "FREE_SERVICE_REQUIRED"
+ | "REWARD_NOT_FOUND"
+ | "REWARD_NOT_AVAILABLE"
+ | "REWARD_CUSTOMER_MISMATCH"
+ | "APPOINTMENT_NOT_ELIGIBLE";
export interface LoyaltySummary {
isEnabled: boolean;
@@ -451,3 +459,284 @@ export async function getLoyaltyHistory(
},
});
}
+
+// ── Owner: programme configuration ─────────────────────────────────────
+
+export interface ProgrammeConfigInput {
+ isEnabled: boolean;
+ stampsRequired: number;
+ rewardType: "PERCENTAGE" | "FIXED_AMOUNT" | "FREE_SERVICE";
+ /** Decimal string — see ServiceCatalogueService's file header for why money is never a JS number. Required for PERCENTAGE/FIXED_AMOUNT, ignored for FREE_SERVICE. */
+ rewardValue?: string;
+ rewardLabel?: string;
+ maxRewardValue?: string;
+ freeServiceId?: string;
+ autoApplyReward: boolean;
+ requiresStaffApproval: boolean;
+ stampsExpireAfterDays?: number;
+ rewardExpiresAfterDays?: number;
+ earnOnCancelled: boolean;
+ earnOnNoShow: boolean;
+ cardBackgroundColor?: string;
+ cardBackgroundImageUrl?: string;
+ stampIcon?: string;
+ termsLine?: string;
+}
+
+/** Only enforced when isEnabled — a half-configured, disabled draft is fine (the owner is still working on it); a half-configured, *enabled* programme would start awarding rewards nobody can redeem correctly. */
+export function validateProgrammeConfig(input: ProgrammeConfigInput): ValidationResult {
+ if (!Number.isInteger(input.stampsRequired) || input.stampsRequired < 1) {
+ return fail({ code: "INVALID_INPUT", message: "Stamps required must be at least 1." });
+ }
+ if (!input.isEnabled) return ok();
+
+ if (input.rewardType === "PERCENTAGE") {
+ const value = input.rewardValue !== undefined ? Number(input.rewardValue) : NaN;
+ if (!Number.isFinite(value) || value <= 0 || value > 100) {
+ return fail({ code: "INVALID_PERCENTAGE", message: "Percentage must be between 1 and 100." });
+ }
+ } else if (input.rewardType === "FIXED_AMOUNT") {
+ const value = input.rewardValue !== undefined ? Number(input.rewardValue) : NaN;
+ if (!Number.isFinite(value) || value < 0) {
+ return fail({ code: "INVALID_VALUE", message: "Enter a fixed reward amount of zero or more." });
+ }
+ } else if (input.rewardType === "FREE_SERVICE" && !input.freeServiceId) {
+ return fail({ code: "FREE_SERVICE_REQUIRED", message: "Choose which service is free." });
+ }
+ return ok();
+}
+
+export async function getProgrammeConfig(prisma: PrismaClient, businessId: string) {
+ return prisma.loyaltyProgramme.findUnique({
+ where: { businessId },
+ include: {
+ eligibleServices: { include: { service: true } },
+ eligibleStaff: true,
+ freeService: true,
+ },
+ });
+}
+
+export async function updateProgrammeConfig(
+ prisma: PrismaClient,
+ params: { businessId: string; input: ProgrammeConfigInput },
+): Promise<{ success: boolean; validation: ValidationResult }> {
+ const validation = validateProgrammeConfig(params.input);
+ if (!validation.valid) return { success: false, validation };
+
+ if (params.input.isEnabled && params.input.rewardType === "FREE_SERVICE" && params.input.freeServiceId) {
+ const service = await prisma.service.findFirst({
+ where: { id: params.input.freeServiceId, businessId: params.businessId, isActive: true },
+ });
+ if (!service) {
+ return {
+ success: false,
+ validation: fail({ code: "FREE_SERVICE_REQUIRED", message: "Choose an active service." }),
+ };
+ }
+ }
+
+ const data = {
+ isEnabled: params.input.isEnabled,
+ stampsRequired: params.input.stampsRequired,
+ rewardType: params.input.rewardType,
+ rewardValue: params.input.rewardType === "FREE_SERVICE" ? null : (params.input.rewardValue ?? null),
+ rewardLabel: params.input.rewardLabel?.trim() || null,
+ maxRewardValue: params.input.maxRewardValue || null,
+ freeServiceId: params.input.rewardType === "FREE_SERVICE" ? (params.input.freeServiceId ?? null) : null,
+ autoApplyReward: params.input.autoApplyReward,
+ requiresStaffApproval: params.input.requiresStaffApproval,
+ stampsExpireAfterDays: params.input.stampsExpireAfterDays ?? null,
+ rewardExpiresAfterDays: params.input.rewardExpiresAfterDays ?? null,
+ earnOnCancelled: params.input.earnOnCancelled,
+ earnOnNoShow: params.input.earnOnNoShow,
+ cardBackgroundColor: params.input.cardBackgroundColor || null,
+ cardBackgroundImageUrl: params.input.cardBackgroundImageUrl || null,
+ stampIcon: params.input.stampIcon || null,
+ termsLine: params.input.termsLine?.trim() || null,
+ };
+
+ await prisma.loyaltyProgramme.upsert({
+ where: { businessId: params.businessId },
+ create: { businessId: params.businessId, ...data },
+ update: data,
+ });
+
+ return { success: true, validation: ok() };
+}
+
+export async function setEligibleServices(
+ prisma: PrismaClient,
+ params: { loyaltyProgrammeId: string; serviceIds: string[] },
+): Promise<{ success: true }> {
+ await prisma.$transaction([
+ prisma.loyaltyEligibleService.deleteMany({ where: { loyaltyProgrammeId: params.loyaltyProgrammeId } }),
+ prisma.loyaltyEligibleService.createMany({
+ data: params.serviceIds.map((serviceId) => ({ loyaltyProgrammeId: params.loyaltyProgrammeId, serviceId })),
+ }),
+ ]);
+ return { success: true };
+}
+
+/** LoyaltyEligibleStaff.staffId has no FK relation in the schema (see schema.prisma) — callers that need staff names for display fetch StaffProfile rows separately and join by id in memory. */
+export async function setEligibleStaff(
+ prisma: PrismaClient,
+ params: { loyaltyProgrammeId: string; staffIds: string[] },
+): Promise<{ success: true }> {
+ await prisma.$transaction([
+ prisma.loyaltyEligibleStaff.deleteMany({ where: { loyaltyProgrammeId: params.loyaltyProgrammeId } }),
+ prisma.loyaltyEligibleStaff.createMany({
+ data: params.staffIds.map((staffId) => ({ loyaltyProgrammeId: params.loyaltyProgrammeId, staffId })),
+ }),
+ ]);
+ return { success: true };
+}
+
+// ── Reward redemption ───────────────────────────────────────────────────
+
+/** Appointments this reward could be applied to right now — same customer, not cancelled, no reward already applied. Shown so staff pick a real, current appointment rather than typing an id. */
+export async function listEligibleAppointmentsForReward(
+ prisma: PrismaClient,
+ params: { customerId: string; businessId: string },
+) {
+ return prisma.appointment.findMany({
+ where: {
+ customerId: params.customerId,
+ businessId: params.businessId,
+ status: { notIn: ["CANCELLED"] },
+ discountAmount: null,
+ },
+ include: { services: { include: { service: true } }, staff: { include: { user: { select: { name: true } } } } },
+ orderBy: { startAt: "desc" },
+ take: 10,
+ });
+}
+
+/**
+ * Redeems an AVAILABLE reward against a specific appointment — the one
+ * place `LoyaltyReward.status`, `LoyaltyAccount.currentStamps`, and
+ * `Appointment.discountAmount` ever change together. The status flip uses
+ * a conditional `updateMany` (`WHERE status = 'AVAILABLE'`) inside the
+ * transaction rather than a read-then-write, so two simultaneous
+ * redemption attempts can't both succeed — the loser sees
+ * REWARD_NOT_AVAILABLE, not a corrupted double-deduction.
+ *
+ * Neither the reward nor the stamps-required count is snapshotted at
+ * *earn* time — redemption always uses the loyalty programme's current
+ * configuration (current stampsRequired, current freeServiceId for a
+ * FREE_SERVICE reward). A documented simplification: if the owner
+ * reconfigures the programme between a reward being earned and redeemed,
+ * redemption reflects the new configuration. See docs/phase-6-summary.md.
+ */
+export async function redeemReward(
+ prisma: PrismaClient,
+ params: { rewardId: string; appointmentId: string; staffId: string; businessId: string },
+): Promise<{ success: boolean; validation: ValidationResult; discountAmount?: string }> {
+ const reward = await prisma.loyaltyReward.findUnique({
+ where: { id: params.rewardId },
+ include: { loyaltyAccount: { include: { loyaltyProgramme: true } } },
+ });
+ if (!reward || reward.loyaltyAccount.loyaltyProgramme.businessId !== params.businessId) {
+ return { success: false, validation: fail({ code: "REWARD_NOT_FOUND", message: "Reward not found." }) };
+ }
+ if (reward.status !== "AVAILABLE") {
+ return {
+ success: false,
+ validation: fail({ code: "REWARD_NOT_AVAILABLE", message: "This reward has already been redeemed or has expired." }),
+ };
+ }
+
+ const appointment = await prisma.appointment.findFirst({
+ where: { id: params.appointmentId, businessId: params.businessId },
+ include: { services: true },
+ });
+ if (!appointment) {
+ return { success: false, validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }) };
+ }
+ if (appointment.customerId !== reward.loyaltyAccount.customerId) {
+ return {
+ success: false,
+ validation: fail({ code: "REWARD_CUSTOMER_MISMATCH", message: "This reward belongs to a different customer." }),
+ };
+ }
+ if (appointment.status === "CANCELLED") {
+ return {
+ success: false,
+ validation: fail({ code: "APPOINTMENT_NOT_ELIGIBLE", message: "Can't apply a reward to a cancelled appointment." }),
+ };
+ }
+ if (appointment.discountAmount !== null) {
+ return {
+ success: false,
+ validation: fail({ code: "APPOINTMENT_NOT_ELIGIBLE", message: "A reward has already been applied to this appointment." }),
+ };
+ }
+
+ const subtotal = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
+ let discountAmount: number;
+ if (reward.rewardType === "PERCENTAGE") {
+ discountAmount = Math.round(subtotal * (Number(reward.value ?? 0) / 100) * 100) / 100;
+ } else if (reward.rewardType === "FIXED_AMOUNT") {
+ discountAmount = Number(reward.value ?? 0);
+ } else {
+ const freeLine = appointment.services.find((s) => s.serviceId === reward.loyaltyAccount.loyaltyProgramme.freeServiceId);
+ if (!freeLine) {
+ return {
+ success: false,
+ validation: fail({
+ code: "APPOINTMENT_NOT_ELIGIBLE",
+ message: "This appointment doesn't include the service this reward covers.",
+ }),
+ };
+ }
+ discountAmount = Number(freeLine.priceAtBooking);
+ }
+ discountAmount = Math.min(Math.max(discountAmount, 0), subtotal);
+ const discountAmountString = discountAmount.toFixed(2);
+
+ const stampsRequired = reward.loyaltyAccount.loyaltyProgramme.stampsRequired;
+
+ const { alreadyRedeemed } = await prisma.$transaction(async (tx) => {
+ const updated = await tx.loyaltyReward.updateMany({
+ where: { id: params.rewardId, status: "AVAILABLE" },
+ data: {
+ status: "REDEEMED",
+ redeemedAt: new Date(),
+ redeemedByStaffId: params.staffId,
+ appliedToAppointmentId: params.appointmentId,
+ },
+ });
+ if (updated.count === 0) return { alreadyRedeemed: true };
+
+ const account = await tx.loyaltyAccount.findUniqueOrThrow({ where: { id: reward.loyaltyAccountId } });
+ const clampedDeduction = Math.min(stampsRequired, account.currentStamps);
+ await tx.loyaltyAccount.update({
+ where: { id: account.id },
+ data: { currentStamps: { decrement: clampedDeduction } },
+ });
+ await tx.loyaltyTransaction.create({
+ data: {
+ loyaltyAccountId: account.id,
+ type: "REWARD_REDEEMED",
+ amount: -clampedDeduction,
+ appointmentId: params.appointmentId,
+ authoredByStaffId: params.staffId,
+ },
+ });
+ await tx.appointment.update({
+ where: { id: params.appointmentId },
+ data: { discountAmount: discountAmountString },
+ });
+
+ return { alreadyRedeemed: false };
+ });
+
+ if (alreadyRedeemed) {
+ return {
+ success: false,
+ validation: fail({ code: "REWARD_NOT_AVAILABLE", message: "This reward has already been redeemed." }),
+ };
+ }
+
+ return { success: true, validation: ok(), discountAmount: discountAmountString };
+}
diff --git a/src/services/reporting/owner-reporting.service.ts b/src/services/reporting/owner-reporting.service.ts
new file mode 100644
index 0000000..358e124
--- /dev/null
+++ b/src/services/reporting/owner-reporting.service.ts
@@ -0,0 +1,272 @@
+import type { PrismaClient } from "@/generated/prisma/client";
+import {
+ calendarDateToUtcMidnight,
+ minutesFromMidnight,
+ weekdayOf,
+} from "@/lib/time/business-time";
+import type { CalendarDate } from "@/lib/time/business-time";
+import { validateProgrammeConfig } from "@/services/loyalty/loyalty.service";
+import { listAppointments } from "@/services/scheduling/appointment.service";
+
+/**
+ * Read-only aggregation for the owner dashboard — every function here
+ * reads across services/appointments/staff and returns a typed summary;
+ * nothing in this file writes anything. Per the brief: only exists because
+ * the dashboard's queries are genuinely more than "call one existing
+ * service and render it" — this is not a general owner data-access layer,
+ * and no other feature should import from it.
+ */
+
+const ACTIVE_STATUSES = ["PENDING", "CONFIRMED", "CHECKED_IN", "IN_PROGRESS"] as const;
+
+export interface DashboardTotals {
+ total: number;
+ completed: number;
+ cancelled: number;
+ noShow: number;
+ inProgress: number;
+ upcoming: number;
+}
+
+export interface PopularService {
+ serviceId: string;
+ name: string;
+ count: number;
+}
+
+export interface ConfigWarning {
+ code: string;
+ message: string;
+}
+
+export interface DashboardSummary {
+ totals: DashboardTotals;
+ staffWorkingToday: { staffId: string; name: string }[];
+ staffOnTimeOffToday: { staffId: string; name: string; reason: string | null }[];
+ /** Booked minutes vs. total working minutes across staff working today — an estimate, not a financial figure, and always labelled as such by the caller. Null when no staff are working today (division by zero). */
+ occupancy: { bookedMinutes: number; availableMinutes: number; percent: number } | null;
+ popularServices: PopularService[];
+ recentLoyaltyActivity: Awaited>;
+ customersWithAvailableRewards: number;
+ warnings: ConfigWarning[];
+}
+
+async function getRecentLoyaltyActivity(prisma: PrismaClient, businessId: string, limit: number) {
+ return prisma.loyaltyTransaction.findMany({
+ where: { loyaltyAccount: { loyaltyProgramme: { businessId } } },
+ orderBy: { createdAt: "desc" },
+ take: limit,
+ include: {
+ loyaltyAccount: { include: { customer: { select: { firstName: true, lastName: true } } } },
+ authoredByStaff: { include: { user: { select: { name: true } } } },
+ },
+ });
+}
+
+export async function getDashboardSummary(
+ prisma: PrismaClient,
+ params: { businessId: string; businessTimezone: string; date: CalendarDate },
+): Promise {
+ const appointments = await listAppointments(prisma, {
+ businessId: params.businessId,
+ fromDate: params.date,
+ toDate: params.date,
+ businessTimezone: params.businessTimezone,
+ });
+
+ const totals: DashboardTotals = {
+ total: appointments.length,
+ completed: appointments.filter((a) => a.status === "COMPLETED").length,
+ cancelled: appointments.filter((a) => a.status === "CANCELLED").length,
+ noShow: appointments.filter((a) => a.status === "NO_SHOW").length,
+ inProgress: appointments.filter((a) => a.status === "IN_PROGRESS").length,
+ upcoming: appointments.filter((a) => (ACTIVE_STATUSES as readonly string[]).includes(a.status)).length,
+ };
+
+ const weekday = weekdayOf(params.date);
+ const dateUtcMidnight = calendarDateToUtcMidnight(params.date);
+
+ const [activeStaff, workingHoursToday, timeOffToday] = await Promise.all([
+ prisma.staffProfile.findMany({
+ where: { businessId: params.businessId, status: "ACTIVE" },
+ include: { user: { select: { name: true } } },
+ }),
+ prisma.workingHours.findMany({
+ where: { staff: { businessId: params.businessId, status: "ACTIVE" }, dayOfWeek: weekday },
+ }),
+ prisma.timeOff.findMany({
+ where: {
+ staff: { businessId: params.businessId, status: "ACTIVE" },
+ status: "APPROVED",
+ startMinutes: null,
+ startDate: { lte: dateUtcMidnight },
+ endDate: { gte: dateUtcMidnight },
+ },
+ }),
+ ]);
+
+ const workingHoursByStaffId = new Map(workingHoursToday.map((h) => [h.staffId, h]));
+ const timeOffStaffIds = new Set(timeOffToday.map((t) => t.staffId));
+ const nameByStaffId = new Map(activeStaff.map((s) => [s.id, s.user.name ?? "Staff"]));
+
+ const staffWorkingToday = activeStaff
+ .filter((s) => {
+ const hours = workingHoursByStaffId.get(s.id);
+ return hours && !hours.isOff && !timeOffStaffIds.has(s.id);
+ })
+ .map((s) => ({ staffId: s.id, name: nameByStaffId.get(s.id) ?? "Staff" }));
+
+ const staffOnTimeOffToday = timeOffToday.map((t) => ({
+ staffId: t.staffId,
+ name: nameByStaffId.get(t.staffId) ?? "Staff",
+ reason: t.reason,
+ }));
+
+ let occupancy: DashboardSummary["occupancy"] = null;
+ if (staffWorkingToday.length > 0) {
+ const availableMinutes = staffWorkingToday.reduce((sum, s) => {
+ const hours = workingHoursByStaffId.get(s.staffId);
+ if (!hours || hours.startMinutes === null || hours.endMinutes === null) return sum;
+ return sum + (hours.endMinutes - hours.startMinutes);
+ }, 0);
+ const bookedMinutes = appointments
+ .filter((a) => (ACTIVE_STATUSES as readonly string[]).includes(a.status) || a.status === "COMPLETED")
+ .reduce((sum, a) => sum + minutesFromMidnight(a.endAt, params.businessTimezone) - minutesFromMidnight(a.startAt, params.businessTimezone), 0);
+ occupancy = {
+ bookedMinutes,
+ availableMinutes,
+ percent: availableMinutes > 0 ? Math.round((bookedMinutes / availableMinutes) * 100) : 0,
+ };
+ }
+
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
+ const recentServiceLines = await prisma.appointmentService.findMany({
+ where: {
+ appointment: { businessId: params.businessId, status: "COMPLETED", startAt: { gte: thirtyDaysAgo } },
+ },
+ include: { service: { select: { id: true, name: true } } },
+ });
+ const serviceCounts = new Map();
+ for (const line of recentServiceLines) {
+ const existing = serviceCounts.get(line.serviceId);
+ if (existing) existing.count += 1;
+ else serviceCounts.set(line.serviceId, { serviceId: line.serviceId, name: line.service.name, count: 1 });
+ }
+ const popularServices = [...serviceCounts.values()].sort((a, b) => b.count - a.count).slice(0, 5);
+
+ const [recentLoyaltyActivity, customersWithAvailableRewards, warnings] = await Promise.all([
+ getRecentLoyaltyActivity(prisma, params.businessId, 5),
+ prisma.loyaltyReward.count({
+ where: { status: "AVAILABLE", loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } } },
+ }),
+ getConfigurationWarnings(prisma, params.businessId),
+ ]);
+
+ return {
+ totals,
+ staffWorkingToday,
+ staffOnTimeOffToday,
+ occupancy,
+ popularServices,
+ recentLoyaltyActivity,
+ customersWithAvailableRewards,
+ warnings,
+ };
+}
+
+export async function getConfigurationWarnings(prisma: PrismaClient, businessId: string): Promise {
+ const warnings: ConfigWarning[] = [];
+
+ const [businessHours, activeStaff, activeServices, loyaltyProgramme] = await Promise.all([
+ prisma.businessHours.findMany({ where: { businessId } }),
+ prisma.staffProfile.findMany({
+ where: { businessId, status: "ACTIVE" },
+ include: { user: { select: { name: true } }, workingHours: true, services: true },
+ }),
+ prisma.service.findMany({
+ where: { businessId, isActive: true },
+ include: { staff: { include: { staff: { select: { status: true } } } } },
+ }),
+ prisma.loyaltyProgramme.findUnique({ where: { businessId } }),
+ ]);
+
+ if (businessHours.length === 0 || businessHours.every((h) => h.isClosed)) {
+ warnings.push({ code: "NO_OPENING_HOURS", message: "The business has no open days configured." });
+ }
+
+ for (const staff of activeStaff) {
+ const hasAnyOpenDay = staff.workingHours.some((h) => !h.isOff);
+ if (!hasAnyOpenDay) {
+ warnings.push({
+ code: "STAFF_NO_WORKING_HOURS",
+ message: `${staff.user.name ?? "A staff member"} has no working hours set — they can never be booked.`,
+ });
+ }
+ }
+
+ for (const service of activeServices) {
+ const hasActiveEligibleStaff = service.staff.some((s) => s.staff.status === "ACTIVE");
+ if (!hasActiveEligibleStaff) {
+ warnings.push({
+ code: "SERVICE_NO_ELIGIBLE_STAFF",
+ message: `"${service.name}" is active but no active staff member is eligible to perform it.`,
+ });
+ }
+ }
+
+ if (loyaltyProgramme?.isEnabled) {
+ const validation = validateProgrammeConfig({
+ isEnabled: loyaltyProgramme.isEnabled,
+ stampsRequired: loyaltyProgramme.stampsRequired,
+ rewardType: loyaltyProgramme.rewardType,
+ rewardValue: loyaltyProgramme.rewardValue?.toString(),
+ freeServiceId: loyaltyProgramme.freeServiceId ?? undefined,
+ autoApplyReward: loyaltyProgramme.autoApplyReward,
+ requiresStaffApproval: loyaltyProgramme.requiresStaffApproval,
+ earnOnCancelled: loyaltyProgramme.earnOnCancelled,
+ earnOnNoShow: loyaltyProgramme.earnOnNoShow,
+ });
+ if (!validation.valid) {
+ warnings.push({
+ code: "LOYALTY_INCOMPLETE",
+ message: `The loyalty programme is enabled but incomplete: ${validation.issues[0]?.message}`,
+ });
+ }
+ }
+
+ const upcomingTimeOffConflicts = await findUpcomingTimeOffConflicts(prisma, businessId);
+ warnings.push(...upcomingTimeOffConflicts);
+
+ return warnings;
+}
+
+/** Whole-day or partial-day time off that overlaps a future, non-cancelled appointment for the same staff member — surfaced as a dashboard warning rather than blocking anything, since the time off may already have been added deliberately (e.g. via Phase 5's own conflict-acknowledge flow). */
+async function findUpcomingTimeOffConflicts(prisma: PrismaClient, businessId: string): Promise {
+ const upcomingTimeOff = await prisma.timeOff.findMany({
+ where: {
+ staff: { businessId },
+ status: "APPROVED",
+ endDate: { gte: calendarDateToUtcMidnight(new Date().toISOString().slice(0, 10)) },
+ },
+ include: { staff: { include: { user: { select: { name: true } } } } },
+ });
+ if (upcomingTimeOff.length === 0) return [];
+
+ const warnings: ConfigWarning[] = [];
+ for (const timeOff of upcomingTimeOff) {
+ const conflictCount = await prisma.appointment.count({
+ where: {
+ staffId: timeOff.staffId,
+ status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
+ startAt: { lt: timeOff.endDate, gte: timeOff.startDate },
+ },
+ });
+ if (conflictCount > 0) {
+ warnings.push({
+ code: "TIME_OFF_CONFLICT",
+ message: `${timeOff.staff.user.name ?? "A staff member"} has ${conflictCount} upcoming appointment${conflictCount === 1 ? "" : "s"} during approved time off.`,
+ });
+ }
+ }
+ return warnings;
+}
diff --git a/src/services/scheduling/__tests__/booking-rule.service.test.ts b/src/services/scheduling/__tests__/booking-rule.service.test.ts
new file mode 100644
index 0000000..bc165a7
--- /dev/null
+++ b/src/services/scheduling/__tests__/booking-rule.service.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from "vitest";
+import { validateBookingRuleInput } from "../booking-rule.service";
+import type { BookingRuleInput } from "../booking-rule.service";
+
+const base: BookingRuleInput = {
+ minNoticeMinutes: 60,
+ maxAdvanceDays: 30,
+ cancellationWindowHours: 24,
+ rescheduleWindowHours: 24,
+ slotIntervalMinutes: 15,
+ bufferBeforeMinutes: 0,
+ bufferAfterMinutes: 0,
+ maxAppointmentsPerDay: null,
+ maxAppointmentsPerStaffPerDay: null,
+ customerCanChooseStaff: true,
+ allowGuestBooking: true,
+ requireApproval: false,
+};
+
+describe("validateBookingRuleInput", () => {
+ it("accepts the default configuration", () => {
+ expect(validateBookingRuleInput(base).valid).toBe(true);
+ });
+
+ it("rejects a negative minimum notice", () => {
+ const result = validateBookingRuleInput({ ...base, minNoticeMinutes: -1 });
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("INVALID_INPUT");
+ });
+
+ it("rejects a maximum advance window under 1 day", () => {
+ const result = validateBookingRuleInput({ ...base, maxAdvanceDays: 0 });
+ expect(result.valid).toBe(false);
+ });
+
+ it("rejects a negative cancellation window", () => {
+ expect(validateBookingRuleInput({ ...base, cancellationWindowHours: -1 }).valid).toBe(false);
+ });
+
+ it("rejects a negative reschedule window", () => {
+ expect(validateBookingRuleInput({ ...base, rescheduleWindowHours: -1 }).valid).toBe(false);
+ });
+
+ it.each([4, 121])("rejects a slot interval outside 5-120 minutes (%s)", (slotIntervalMinutes) => {
+ expect(validateBookingRuleInput({ ...base, slotIntervalMinutes }).valid).toBe(false);
+ });
+
+ it.each([5, 120])("accepts slot interval boundaries (%s)", (slotIntervalMinutes) => {
+ expect(validateBookingRuleInput({ ...base, slotIntervalMinutes }).valid).toBe(true);
+ });
+
+ it("rejects a negative buffer", () => {
+ expect(validateBookingRuleInput({ ...base, bufferBeforeMinutes: -5 }).valid).toBe(false);
+ });
+
+ it("accepts null max-appointments-per-day (unlimited)", () => {
+ expect(validateBookingRuleInput({ ...base, maxAppointmentsPerDay: null }).valid).toBe(true);
+ });
+
+ it("rejects a zero max-appointments-per-day", () => {
+ expect(validateBookingRuleInput({ ...base, maxAppointmentsPerDay: 0 }).valid).toBe(false);
+ });
+
+ it("rejects a zero max-appointments-per-staff-per-day", () => {
+ expect(validateBookingRuleInput({ ...base, maxAppointmentsPerStaffPerDay: 0 }).valid).toBe(false);
+ });
+
+ it("accepts a positive max-appointments-per-staff-per-day", () => {
+ expect(validateBookingRuleInput({ ...base, maxAppointmentsPerStaffPerDay: 5 }).valid).toBe(true);
+ });
+});
diff --git a/src/services/scheduling/booking-rule.service.ts b/src/services/scheduling/booking-rule.service.ts
new file mode 100644
index 0000000..e645192
--- /dev/null
+++ b/src/services/scheduling/booking-rule.service.ts
@@ -0,0 +1,72 @@
+import type { PrismaClient } from "@/generated/prisma/client";
+import { fail, ok } from "@/lib/validation-result";
+import type { ValidationResult } from "@/lib/validation-result";
+import type { BookingRuleConfig } from "./business-rules.service";
+
+/**
+ * Read/write for the BookingRule row itself — deliberately separate from
+ * business-rules.service.ts, which is pure evaluation logic with no Prisma
+ * access by design (see that file's own header). This file owns the one
+ * thing that one doesn't: loading and validating the actual configuration
+ * an owner edits, which every booking-time check then reads via
+ * AvailabilityService exactly as before — nothing about how rules are
+ * *evaluated* changes here, only how they're *set*.
+ */
+
+export type BookingRuleErrorCode = "INVALID_INPUT";
+
+export type BookingRuleInput = BookingRuleConfig;
+
+export function validateBookingRuleInput(input: BookingRuleInput): ValidationResult {
+ if (!Number.isInteger(input.minNoticeMinutes) || input.minNoticeMinutes < 0) {
+ return fail({ code: "INVALID_INPUT", message: "Minimum notice can't be negative." });
+ }
+ if (!Number.isInteger(input.maxAdvanceDays) || input.maxAdvanceDays < 1) {
+ return fail({ code: "INVALID_INPUT", message: "Maximum advance booking must be at least 1 day." });
+ }
+ if (!Number.isInteger(input.cancellationWindowHours) || input.cancellationWindowHours < 0) {
+ return fail({ code: "INVALID_INPUT", message: "Cancellation window can't be negative." });
+ }
+ if (!Number.isInteger(input.rescheduleWindowHours) || input.rescheduleWindowHours < 0) {
+ return fail({ code: "INVALID_INPUT", message: "Reschedule window can't be negative." });
+ }
+ if (!Number.isInteger(input.slotIntervalMinutes) || input.slotIntervalMinutes < 5 || input.slotIntervalMinutes > 120) {
+ return fail({ code: "INVALID_INPUT", message: "Slot interval must be between 5 and 120 minutes." });
+ }
+ if (!Number.isInteger(input.bufferBeforeMinutes) || input.bufferBeforeMinutes < 0) {
+ return fail({ code: "INVALID_INPUT", message: "Buffer before can't be negative." });
+ }
+ if (!Number.isInteger(input.bufferAfterMinutes) || input.bufferAfterMinutes < 0) {
+ return fail({ code: "INVALID_INPUT", message: "Buffer after can't be negative." });
+ }
+ if (input.maxAppointmentsPerDay !== null && (!Number.isInteger(input.maxAppointmentsPerDay) || input.maxAppointmentsPerDay < 1)) {
+ return fail({ code: "INVALID_INPUT", message: "Max appointments per day must be at least 1, or left unlimited." });
+ }
+ if (
+ input.maxAppointmentsPerStaffPerDay !== null &&
+ (!Number.isInteger(input.maxAppointmentsPerStaffPerDay) || input.maxAppointmentsPerStaffPerDay < 1)
+ ) {
+ return fail({ code: "INVALID_INPUT", message: "Max appointments per staff member per day must be at least 1, or left unlimited." });
+ }
+ return ok();
+}
+
+export async function getBookingRule(prisma: PrismaClient, businessId: string) {
+ return prisma.bookingRule.findUnique({ where: { businessId } });
+}
+
+export async function updateBookingRule(
+ prisma: PrismaClient,
+ params: { businessId: string; input: BookingRuleInput },
+): Promise<{ success: boolean; validation: ValidationResult }> {
+ const validation = validateBookingRuleInput(params.input);
+ if (!validation.valid) return { success: false, validation };
+
+ await prisma.bookingRule.upsert({
+ where: { businessId: params.businessId },
+ create: { businessId: params.businessId, ...params.input },
+ update: { ...params.input },
+ });
+
+ return { success: true, validation: ok() };
+}
diff --git a/src/services/staff/__tests__/staff-owner.integration.test.ts b/src/services/staff/__tests__/staff-owner.integration.test.ts
new file mode 100644
index 0000000..a88a4f4
--- /dev/null
+++ b/src/services/staff/__tests__/staff-owner.integration.test.ts
@@ -0,0 +1,244 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+ createStaffProfile,
+ deactivateStaff,
+ reactivateStaff,
+ setStaffServiceEligibility,
+ updateStaffAsOwner,
+} from "../staff.service";
+import {
+ cleanupTestBusiness,
+ createTestBusiness,
+ createTestCustomer,
+ createTestStaff,
+ prisma,
+} from "@/services/scheduling/__tests__/fixtures";
+import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
+
+/**
+ * Covers the owner-only staff management additions from Phase 6:
+ * onboarding, deactivation/last-owner protection, and service-eligibility
+ * conflict review. Uses createTestStaff's auto-created role (no
+ * MANAGE_STAFF permission) as the "ordinary barber" role, and a
+ * hand-created role with MANAGE_STAFF as the "owner" role — mirroring how
+ * countOtherActiveManagers/staffCurrentlyManagesStaff actually define "is
+ * an owner" (see staff.service.ts).
+ */
+describe("StaffService — owner actions (integration)", () => {
+ let business: TestBusiness;
+
+ beforeEach(async () => {
+ business = await createTestBusiness();
+ });
+
+ afterEach(async () => {
+ await cleanupTestBusiness(business);
+ });
+
+ async function createOwnerRole() {
+ return prisma.role.create({
+ data: {
+ businessId: business.businessId,
+ name: "Owner",
+ permissions: { create: [{ permission: "MANAGE_STAFF" }] },
+ },
+ });
+ }
+
+ async function createBarberRole() {
+ return prisma.role.create({ data: { businessId: business.businessId, name: "Barber" } });
+ }
+
+ describe("createStaffProfile", () => {
+ it("creates a fully usable User + StaffProfile + eligibility in one go", async () => {
+ const role = await createBarberRole();
+ const result = await createStaffProfile(prisma, {
+ businessId: business.businessId,
+ input: {
+ name: "New Barber",
+ email: "new.barber@test.local",
+ roleId: role.id,
+ serviceIds: [business.haircutServiceId],
+ isBookableOnline: true,
+ },
+ });
+ expect(result.success).toBe(true);
+ if (!result.success) throw new Error("unreachable");
+ business.createdUserIds.push(result.staff.userId);
+
+ const eligibility = await prisma.staffService.findMany({ where: { staffId: result.staff.id } });
+ expect(eligibility.map((e) => e.serviceId)).toEqual([business.haircutServiceId]);
+ });
+
+ it("rejects an email already in use", async () => {
+ const role = await createBarberRole();
+ const { userId } = await createTestStaff(business);
+ const existingUser = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
+
+ const result = await createStaffProfile(prisma, {
+ businessId: business.businessId,
+ input: {
+ name: "Duplicate",
+ email: existingUser.email!,
+ roleId: role.id,
+ serviceIds: [],
+ isBookableOnline: true,
+ },
+ });
+ expect(result.success).toBe(false);
+ if (result.success) throw new Error("unreachable");
+ expect(result.validation.issues[0]?.code).toBe("EMAIL_ALREADY_IN_USE");
+ });
+ });
+
+ describe("last-owner protection", () => {
+ it("blocks deactivating the only active owner", async () => {
+ const ownerRole = await createOwnerRole();
+ const { staffId: ownerStaffId, userId } = await createTestStaff(business);
+ await prisma.staffProfile.update({ where: { id: ownerStaffId }, data: { roleId: ownerRole.id } });
+ void userId;
+
+ const result = await deactivateStaff(prisma, { staffId: ownerStaffId, businessId: business.businessId });
+ expect(result.success).toBe(false);
+ if (result.success) throw new Error("unreachable");
+ expect(result.validation.issues[0]?.code).toBe("LAST_OWNER");
+
+ const stillActive = await prisma.staffProfile.findUniqueOrThrow({ where: { id: ownerStaffId } });
+ expect(stillActive.status).toBe("ACTIVE");
+ });
+
+ it("allows deactivating an owner when another active owner remains", async () => {
+ const ownerRole = await createOwnerRole();
+ const { staffId: firstOwnerId } = await createTestStaff(business);
+ const { staffId: secondOwnerId } = await createTestStaff(business);
+ await prisma.staffProfile.updateMany({
+ where: { id: { in: [firstOwnerId, secondOwnerId] } },
+ data: { roleId: ownerRole.id },
+ });
+
+ const result = await deactivateStaff(prisma, { staffId: firstOwnerId, businessId: business.businessId });
+ expect(result.success).toBe(true);
+
+ const deactivated = await prisma.staffProfile.findUniqueOrThrow({ where: { id: firstOwnerId } });
+ expect(deactivated.status).toBe("INACTIVE");
+ });
+
+ it("blocks demoting the only active owner's role away from MANAGE_STAFF", async () => {
+ const ownerRole = await createOwnerRole();
+ const barberRole = await createBarberRole();
+ const { staffId: ownerStaffId } = await createTestStaff(business);
+ await prisma.staffProfile.update({ where: { id: ownerStaffId }, data: { roleId: ownerRole.id } });
+
+ const result = await updateStaffAsOwner(prisma, {
+ staffId: ownerStaffId,
+ businessId: business.businessId,
+ input: { roleId: barberRole.id },
+ });
+ expect(result.success).toBe(false);
+ if (result.success) throw new Error("unreachable");
+ expect(result.validation.issues[0]?.code).toBe("LAST_OWNER");
+
+ const stillOwner = await prisma.staffProfile.findUniqueOrThrow({ where: { id: ownerStaffId } });
+ expect(stillOwner.roleId).toBe(ownerRole.id);
+ });
+
+ it("reactivating a deactivated staff member restores ACTIVE status", async () => {
+ const { staffId } = await createTestStaff(business);
+ await prisma.staffProfile.update({ where: { id: staffId }, data: { status: "INACTIVE" } });
+
+ const result = await reactivateStaff(prisma, { staffId, businessId: business.businessId });
+ expect(result.success).toBe(true);
+
+ const staff = await prisma.staffProfile.findUniqueOrThrow({ where: { id: staffId } });
+ expect(staff.status).toBe("ACTIVE");
+ });
+ });
+
+ describe("deactivation appointment conflicts", () => {
+ it("blocks deactivating a staff member with a future appointment, and Save Anyway bypasses it without cancelling it", async () => {
+ const { staffId } = await createTestStaff(business);
+ const { customerId } = await createTestCustomer(business);
+ const appointment = await prisma.appointment.create({
+ data: {
+ businessId: business.businessId,
+ staffId,
+ customerId,
+ status: "CONFIRMED",
+ startAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000),
+ endAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000 + 30 * 60_000),
+ services: {
+ create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
+ },
+ },
+ });
+
+ const blocked = await deactivateStaff(prisma, { staffId, businessId: business.businessId });
+ expect(blocked.success).toBe(false);
+ if (blocked.success) throw new Error("unreachable");
+ expect(blocked.conflicts.map((c) => c.id)).toContain(appointment.id);
+
+ const acknowledged = await deactivateStaff(prisma, {
+ staffId,
+ businessId: business.businessId,
+ acknowledgeConflicts: true,
+ });
+ expect(acknowledged.success).toBe(true);
+
+ const stillThere = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
+ expect(stillThere.status).toBe("CONFIRMED");
+ expect(stillThere.staffId).toBe(staffId);
+ });
+ });
+
+ describe("setStaffServiceEligibility", () => {
+ it("blocks removing a service that has a future appointment booked, and Save Anyway bypasses it", async () => {
+ const { staffId } = await createTestStaff(business);
+ const { customerId } = await createTestCustomer(business);
+ await prisma.appointment.create({
+ data: {
+ businessId: business.businessId,
+ staffId,
+ customerId,
+ status: "CONFIRMED",
+ startAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000),
+ endAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000 + 30 * 60_000),
+ services: {
+ create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
+ },
+ },
+ });
+
+ // Existing fixture staff is eligible for both haircut and beard trim
+ // (see createTestStaff) — removing haircut should conflict.
+ const blocked = await setStaffServiceEligibility(prisma, {
+ staffId,
+ businessId: business.businessId,
+ serviceIds: [business.beardTrimServiceId],
+ });
+ expect(blocked.success).toBe(false);
+ if (blocked.success) throw new Error("unreachable");
+ expect(blocked.conflicts.length).toBeGreaterThan(0);
+
+ const acknowledged = await setStaffServiceEligibility(prisma, {
+ staffId,
+ businessId: business.businessId,
+ serviceIds: [business.beardTrimServiceId],
+ acknowledgeConflicts: true,
+ });
+ expect(acknowledged.success).toBe(true);
+
+ const eligibility = await prisma.staffService.findMany({ where: { staffId } });
+ expect(eligibility.map((e) => e.serviceId)).toEqual([business.beardTrimServiceId]);
+ });
+
+ it("adding a service never triggers a conflict", async () => {
+ const { staffId } = await createTestStaff(business);
+ const result = await setStaffServiceEligibility(prisma, {
+ staffId,
+ businessId: business.businessId,
+ serviceIds: [business.haircutServiceId, business.beardTrimServiceId],
+ });
+ expect(result.success).toBe(true);
+ });
+ });
+});
diff --git a/src/services/staff/__tests__/staff.service.test.ts b/src/services/staff/__tests__/staff.service.test.ts
index 48d6d8a..b9e9cf6 100644
--- a/src/services/staff/__tests__/staff.service.test.ts
+++ b/src/services/staff/__tests__/staff.service.test.ts
@@ -4,6 +4,7 @@ import {
StaffPermissionDeniedError,
assertOwnsResource,
validateBreakInput,
+ validateCreateStaffInput,
validateTimeOffInput,
validateWorkingHoursInput,
} from "../staff.service";
@@ -135,3 +136,38 @@ describe("validateTimeOffInput", () => {
expect(result.valid).toBe(true);
});
});
+
+describe("validateCreateStaffInput", () => {
+ const base = {
+ name: "Owen Clarke",
+ email: "owen.clarke@blakeheritage.co.uk",
+ roleId: "role-barber",
+ serviceIds: [] as string[],
+ isBookableOnline: true,
+ };
+
+ it("accepts a valid new-staff input", () => {
+ expect(validateCreateStaffInput(base).valid).toBe(true);
+ });
+
+ it("rejects an empty name", () => {
+ const result = validateCreateStaffInput({ ...base, name: " " });
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("INVALID_INPUT");
+ });
+
+ it.each(["not-an-email", "missing-domain@", "@missing-local.com", ""])(
+ "rejects an invalid email %s",
+ (email) => {
+ const result = validateCreateStaffInput({ ...base, email });
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("INVALID_INPUT");
+ },
+ );
+
+ it("rejects a missing role", () => {
+ const result = validateCreateStaffInput({ ...base, roleId: "" });
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("INVALID_INPUT");
+ });
+});
diff --git a/src/services/staff/staff-invitation.service.ts b/src/services/staff/staff-invitation.service.ts
new file mode 100644
index 0000000..c960bdb
--- /dev/null
+++ b/src/services/staff/staff-invitation.service.ts
@@ -0,0 +1,103 @@
+import { randomBytes } from "node:crypto";
+import bcrypt from "bcryptjs";
+import type { PrismaClient } from "@/generated/prisma/client";
+
+/**
+ * The "invite a new staff member" flow, deliberately split out of
+ * StaffService (see docs/phase-6-summary.md, "Domain services" — the
+ * brief's own instruction not to mix invitation delivery into StaffService).
+ * StaffService.createStaffProfile makes the User/StaffProfile usable
+ * immediately; this file only tracks the separate "has this person set
+ * their own password yet" signal via StaffInvitation.acceptedAt.
+ *
+ * No real email delivery this phase — `token` is a plain opaque value
+ * (crypto-random, not HMAC-signed like the booking-draft cookie, because
+ * it's validated against a database row rather than needing to be
+ * self-contained) surfaced to the owner as a copy-link
+ * (`/invite/accept?token=...`) to send however they like. This is a
+ * documented development-safe shortcut, not a production email system.
+ */
+
+const INVITATION_TTL_DAYS = 7;
+
+export type StaffInvitationErrorCode = "INVALID_TOKEN" | "EXPIRED" | "ALREADY_ACCEPTED" | "INVALID_INPUT";
+
+function generateToken(): string {
+ return randomBytes(32).toString("base64url");
+}
+
+export async function createInvitation(
+ prisma: PrismaClient,
+ params: { businessId: string; staffId: string; email: string; invitedByUserId?: string },
+) {
+ return prisma.staffInvitation.create({
+ data: {
+ businessId: params.businessId,
+ staffId: params.staffId,
+ email: params.email,
+ token: generateToken(),
+ invitedByUserId: params.invitedByUserId,
+ expiresAt: new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000),
+ },
+ });
+}
+
+/** An expired or already-viewed invitation gets a fresh token/expiry rather than a second row — one StaffInvitation per StaffProfile (see the schema's `staffId @unique`). */
+export async function regenerateInvitation(
+ prisma: PrismaClient,
+ params: { staffId: string; businessId: string },
+) {
+ const existing = await prisma.staffInvitation.findFirst({
+ where: { staffId: params.staffId, businessId: params.businessId },
+ });
+ if (!existing) return null;
+
+ return prisma.staffInvitation.update({
+ where: { id: existing.id },
+ data: { token: generateToken(), expiresAt: new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000), acceptedAt: null },
+ });
+}
+
+export async function getInvitationByToken(prisma: PrismaClient, token: string) {
+ return prisma.staffInvitation.findUnique({
+ where: { token },
+ include: {
+ staff: { include: { user: { select: { id: true, name: true, email: true } }, business: { select: { name: true } } } },
+ },
+ });
+}
+
+export type InvitationCheck =
+ | { valid: true; invitation: NonNullable>> }
+ | { valid: false; reason: StaffInvitationErrorCode };
+
+export async function validateInvitationToken(prisma: PrismaClient, token: string): Promise {
+ const invitation = await getInvitationByToken(prisma, token);
+ if (!invitation) return { valid: false, reason: "INVALID_TOKEN" };
+ if (invitation.acceptedAt) return { valid: false, reason: "ALREADY_ACCEPTED" };
+ if (invitation.expiresAt < new Date()) return { valid: false, reason: "EXPIRED" };
+ return { valid: true, invitation };
+}
+
+export async function acceptInvitation(
+ prisma: PrismaClient,
+ params: { token: string; password: string },
+): Promise<{ success: boolean; reason?: StaffInvitationErrorCode }> {
+ if (params.password.length < 8) return { success: false, reason: "INVALID_INPUT" };
+
+ const check = await validateInvitationToken(prisma, params.token);
+ if (!check.valid) return { success: false, reason: check.reason };
+
+ const passwordHash = await bcrypt.hash(params.password, 12);
+ await prisma.$transaction([
+ prisma.user.update({
+ where: { id: check.invitation.staff.userId },
+ data: { passwordHash, emailVerified: new Date() },
+ }),
+ prisma.staffInvitation.update({
+ where: { id: check.invitation.id },
+ data: { acceptedAt: new Date() },
+ }),
+ ]);
+ return { success: true };
+}
diff --git a/src/services/staff/staff.service.ts b/src/services/staff/staff.service.ts
index 0cf3c8e..7325912 100644
--- a/src/services/staff/staff.service.ts
+++ b/src/services/staff/staff.service.ts
@@ -31,7 +31,10 @@ export type StaffServiceErrorCode =
| "BREAK_NOT_FOUND"
| "TIME_OFF_NOT_FOUND"
| "APPOINTMENT_CONFLICTS"
- | "PERMISSION_DENIED";
+ | "PERMISSION_DENIED"
+ | "EMAIL_ALREADY_IN_USE"
+ | "ROLE_NOT_FOUND"
+ | "LAST_OWNER";
export class StaffPermissionDeniedError extends Error {
constructor(message = "You don't have permission to manage this staff member's schedule.") {
@@ -625,3 +628,360 @@ export async function setNotificationPreferences(
);
return { success: true };
}
+
+// ── Owner: team management ─────────────────────────────────────────────
+// Everything below acts on staff *other than* the caller — the owner's own
+// business-membership must already be verified by the caller (see
+// src/features/owner/authorization.ts); every function here additionally
+// re-scopes by businessId itself rather than trusting the caller alone.
+
+export async function listStaffForBusiness(
+ prisma: PrismaClient,
+ params: { businessId: string; q?: string; includeInactive?: boolean },
+) {
+ return prisma.staffProfile.findMany({
+ where: {
+ businessId: params.businessId,
+ ...(params.includeInactive ? {} : { status: { not: "INACTIVE" } }),
+ ...(params.q
+ ? {
+ user: {
+ OR: [
+ { name: { contains: params.q, mode: "insensitive" } },
+ { email: { contains: params.q, mode: "insensitive" } },
+ ],
+ },
+ }
+ : {}),
+ },
+ include: {
+ user: { select: { id: true, name: true, email: true, phone: true, image: true } },
+ role: { select: { id: true, name: true } },
+ invitation: true,
+ },
+ orderBy: { createdAt: "asc" },
+ });
+}
+
+export async function getStaffDetailById(prisma: PrismaClient, params: { staffId: string; businessId: string }) {
+ return prisma.staffProfile.findFirst({
+ where: { id: params.staffId, businessId: params.businessId },
+ include: {
+ user: { select: { id: true, name: true, email: true, phone: true, image: true } },
+ role: { include: { permissions: true } },
+ services: { include: { service: true } },
+ invitation: true,
+ },
+ });
+}
+
+export async function listRolesForBusiness(prisma: PrismaClient, businessId: string) {
+ return prisma.role.findMany({
+ where: { businessId },
+ include: { permissions: true },
+ orderBy: { name: "asc" },
+ });
+}
+
+/** A role holding MANAGE_STAFF is this app's definition of "an owner" — see docs/phase-6-summary.md, "Roles and permissions". */
+async function countOtherActiveManagers(
+ prisma: PrismaClient,
+ params: { businessId: string; excludeStaffId: string },
+) {
+ return prisma.staffProfile.count({
+ where: {
+ businessId: params.businessId,
+ status: "ACTIVE",
+ id: { not: params.excludeStaffId },
+ role: { permissions: { some: { permission: "MANAGE_STAFF" } } },
+ },
+ });
+}
+
+async function staffCurrentlyManagesStaff(prisma: PrismaClient, staffId: string): Promise {
+ const staff = await prisma.staffProfile.findUnique({
+ where: { id: staffId },
+ select: { status: true, role: { select: { permissions: { select: { permission: true } } } } },
+ });
+ return (
+ !!staff &&
+ staff.status === "ACTIVE" &&
+ staff.role.permissions.some((p) => p.permission === "MANAGE_STAFF")
+ );
+}
+
+export interface CreateStaffInput {
+ name: string;
+ email: string;
+ phone?: string;
+ image?: string;
+ bio?: string;
+ roleId: string;
+ serviceIds: string[];
+ isBookableOnline: boolean;
+}
+
+export function validateCreateStaffInput(input: CreateStaffInput): ValidationResult {
+ if (!input.name.trim()) return fail({ code: "INVALID_INPUT", message: "Name is required." });
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) {
+ return fail({ code: "INVALID_INPUT", message: "Enter a valid email address." });
+ }
+ if (!input.roleId) return fail({ code: "INVALID_INPUT", message: "Choose a role." });
+ return ok();
+}
+
+/** Creates the User + StaffProfile + initial service eligibility immediately — the profile is fully usable right away. Working hours default to "off every day" until the owner (or the staff member, once they've accepted) sets them; StaffInvitationService is what tracks acceptance, deliberately kept separate — see that file. */
+export async function createStaffProfile(
+ prisma: PrismaClient,
+ params: { businessId: string; input: CreateStaffInput },
+) {
+ const validation = validateCreateStaffInput(params.input);
+ if (!validation.valid) return { success: false as const, validation };
+
+ const [role, existingUser] = await Promise.all([
+ prisma.role.findFirst({ where: { id: params.input.roleId, businessId: params.businessId } }),
+ prisma.user.findUnique({ where: { email: params.input.email.toLowerCase() } }),
+ ]);
+ if (!role) {
+ return { success: false as const, validation: fail({ code: "ROLE_NOT_FOUND", message: "Role not found." }) };
+ }
+ if (existingUser) {
+ return {
+ success: false as const,
+ validation: fail({ code: "EMAIL_ALREADY_IN_USE", message: "That email is already in use." }),
+ };
+ }
+
+ const user = await prisma.user.create({
+ data: {
+ name: params.input.name.trim(),
+ email: params.input.email.toLowerCase(),
+ phone: params.input.phone || null,
+ image: params.input.image || null,
+ staffProfile: {
+ create: {
+ businessId: params.businessId,
+ roleId: params.input.roleId,
+ bio: params.input.bio?.trim() || null,
+ isBookableOnline: params.input.isBookableOnline,
+ services: { create: params.input.serviceIds.map((serviceId) => ({ serviceId })) },
+ },
+ },
+ },
+ include: { staffProfile: true },
+ });
+
+ return { success: true as const, staff: user.staffProfile!, validation: ok() };
+}
+
+export interface UpdateStaffAsOwnerInput {
+ name?: string;
+ phone?: string;
+ image?: string;
+ bio?: string;
+ isBookableOnline?: boolean;
+ roleId?: string;
+}
+
+export async function updateStaffAsOwner(
+ prisma: PrismaClient,
+ params: { staffId: string; businessId: string; input: UpdateStaffAsOwnerInput },
+) {
+ const existing = await prisma.staffProfile.findFirst({
+ where: { id: params.staffId, businessId: params.businessId },
+ });
+ if (!existing) {
+ return { success: false as const, validation: fail({ code: "STAFF_NOT_FOUND", message: "Staff member not found." }) };
+ }
+
+ if (params.input.roleId && params.input.roleId !== existing.roleId) {
+ const role = await prisma.role.findFirst({ where: { id: params.input.roleId, businessId: params.businessId } });
+ if (!role) {
+ return { success: false as const, validation: fail({ code: "ROLE_NOT_FOUND", message: "Role not found." }) };
+ }
+ const wasManager = await staffCurrentlyManagesStaff(prisma, params.staffId);
+ const stillManagesAfter = !!(await prisma.rolePermission.findFirst({
+ where: { roleId: role.id, permission: "MANAGE_STAFF" },
+ }));
+ if (wasManager && !stillManagesAfter) {
+ const otherManagers = await countOtherActiveManagers(prisma, {
+ businessId: params.businessId,
+ excludeStaffId: params.staffId,
+ });
+ if (otherManagers === 0) {
+ return {
+ success: false as const,
+ validation: fail({
+ code: "LAST_OWNER",
+ message: "This is the only active owner — assign another owner first.",
+ }),
+ };
+ }
+ }
+ }
+
+ await prisma.$transaction([
+ prisma.user.update({
+ where: { id: existing.userId },
+ data: {
+ ...(params.input.name !== undefined ? { name: params.input.name } : {}),
+ ...(params.input.phone !== undefined ? { phone: params.input.phone || null } : {}),
+ ...(params.input.image !== undefined ? { image: params.input.image || null } : {}),
+ },
+ }),
+ prisma.staffProfile.update({
+ where: { id: params.staffId },
+ data: {
+ ...(params.input.bio !== undefined ? { bio: params.input.bio || null } : {}),
+ ...(params.input.isBookableOnline !== undefined
+ ? { isBookableOnline: params.input.isBookableOnline }
+ : {}),
+ ...(params.input.roleId !== undefined ? { roleId: params.input.roleId } : {}),
+ },
+ }),
+ ]);
+
+ return { success: true as const, validation: ok() };
+}
+
+/** Future, non-cancelled appointments still assigned to this staff member — shown for acknowledgement before deactivating, never auto-resolved. Deactivating does not touch these rows; it only removes the staff member from future booking/availability. */
+export async function findStaffDeactivationConflicts(
+ prisma: PrismaClient,
+ params: { staffId: string },
+) {
+ return prisma.appointment.findMany({
+ where: {
+ staffId: params.staffId,
+ status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
+ startAt: { gte: new Date() },
+ },
+ include: { customer: true, services: { include: { service: true } } },
+ orderBy: { startAt: "asc" },
+ });
+}
+
+export async function deactivateStaff(
+ prisma: PrismaClient,
+ params: { staffId: string; businessId: string; acknowledgeConflicts?: boolean },
+) {
+ const existing = await prisma.staffProfile.findFirst({
+ where: { id: params.staffId, businessId: params.businessId },
+ });
+ if (!existing) {
+ return {
+ success: false as const,
+ validation: fail({ code: "STAFF_NOT_FOUND", message: "Staff member not found." }),
+ conflicts: [],
+ };
+ }
+
+ const wasManager = await staffCurrentlyManagesStaff(prisma, params.staffId);
+ if (wasManager) {
+ const otherManagers = await countOtherActiveManagers(prisma, {
+ businessId: params.businessId,
+ excludeStaffId: params.staffId,
+ });
+ if (otherManagers === 0) {
+ return {
+ success: false as const,
+ validation: fail({
+ code: "LAST_OWNER",
+ message: "This is the only active owner — assign another owner first.",
+ }),
+ conflicts: [],
+ };
+ }
+ }
+
+ if (!params.acknowledgeConflicts) {
+ const conflicts = await findStaffDeactivationConflicts(prisma, { staffId: params.staffId });
+ if (conflicts.length > 0) {
+ return {
+ success: false as const,
+ conflicts,
+ validation: fail({
+ code: "APPOINTMENT_CONFLICTS",
+ message: "This staff member still has upcoming appointments.",
+ }),
+ };
+ }
+ }
+
+ await prisma.staffProfile.update({ where: { id: params.staffId }, data: { status: "INACTIVE" } });
+ return { success: true as const, conflicts: [], validation: ok() };
+}
+
+export async function reactivateStaff(
+ prisma: PrismaClient,
+ params: { staffId: string; businessId: string },
+): Promise<{ success: boolean }> {
+ const result = await prisma.staffProfile.updateMany({
+ where: { id: params.staffId, businessId: params.businessId },
+ data: { status: "ACTIVE" },
+ });
+ return { success: result.count > 0 };
+}
+
+/** Future, non-cancelled appointments this staff member has that book a service being *removed* from their eligible list — the only ones that matter, since keeping or adding eligibility can never orphan an existing booking. */
+export async function findServiceEligibilityConflicts(
+ prisma: PrismaClient,
+ params: { staffId: string; removedServiceIds: string[] },
+) {
+ if (params.removedServiceIds.length === 0) return [];
+ return prisma.appointment.findMany({
+ where: {
+ staffId: params.staffId,
+ status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
+ startAt: { gte: new Date() },
+ services: { some: { serviceId: { in: params.removedServiceIds } } },
+ },
+ include: { customer: true, services: { include: { service: true } } },
+ orderBy: { startAt: "asc" },
+ });
+}
+
+export async function setStaffServiceEligibility(
+ prisma: PrismaClient,
+ params: { staffId: string; businessId: string; serviceIds: string[]; acknowledgeConflicts?: boolean },
+) {
+ const existing = await prisma.staffProfile.findFirst({
+ where: { id: params.staffId, businessId: params.businessId },
+ include: { services: true },
+ });
+ if (!existing) {
+ return {
+ success: false as const,
+ validation: fail({ code: "STAFF_NOT_FOUND", message: "Staff member not found." }),
+ conflicts: [],
+ };
+ }
+
+ const currentServiceIds = existing.services.map((s) => s.serviceId);
+ const removedServiceIds = currentServiceIds.filter((id) => !params.serviceIds.includes(id));
+
+ if (!params.acknowledgeConflicts) {
+ const conflicts = await findServiceEligibilityConflicts(prisma, {
+ staffId: params.staffId,
+ removedServiceIds,
+ });
+ if (conflicts.length > 0) {
+ return {
+ success: false as const,
+ conflicts,
+ validation: fail({
+ code: "APPOINTMENT_CONFLICTS",
+ message: "This staff member has upcoming appointments for a service you're removing.",
+ }),
+ };
+ }
+ }
+
+ await prisma.$transaction([
+ prisma.staffService.deleteMany({ where: { staffId: params.staffId } }),
+ prisma.staffService.createMany({
+ data: params.serviceIds.map((serviceId) => ({ staffId: params.staffId, serviceId })),
+ }),
+ ]);
+
+ return { success: true as const, conflicts: [], validation: ok() };
+}