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

31 KiB
Raw Permalink Blame History

Phase 6 — Owner Administration: Summary

The complete owner administration experience, in the same application as the customer and barber experiences: dashboard, all-staff calendar, business details/hours, staff management and invitations, roles and service eligibility, service categories and services, booking rules, loyalty programme configuration and reward redemption, and business-wide settings. No separate owner app — owner is a permission on a staff account (the seeded owner, Marcus Reed, is also a working barber), reusing the existing auth/permission system, the (staff)-pattern shell, and the design system throughout. Four new focused domain services were added (BusinessService, ServiceCatalogueService, a small booking-rule.service.ts, and OwnerReportingService); everything else extends StaffService/LoyaltyService or is reused as-is.


1. What was built

  • Owner Dashboard (/owner) — real, data-backed: today's appointment totals by status, staff working today, estimated occupancy (clearly labelled as an estimate, no invented revenue), popular services (last 30 days), recent loyalty activity, customers with an available reward, and configuration warnings (no opening hours, staff with no working hours, a service with no eligible active staff, an incomplete-but-enabled loyalty programme, time-off/appointment conflicts).
  • All-Staff Calendar (/owner/calendar) — Day and Agenda views, staff filter pills, current-time indicator, reuses the exact same DayCalendar/DateStrip/AppointmentCard components the barber calendar uses (parametrized, not forked). Owner can view any appointment, create one (via the existing /staff/book flow — never duplicated), block time for any staff member, and reschedule/reassign.
  • Appointment Reassignment — "Reschedule / Reassign" on the owner appointment details page reuses BookingService.rescheduleBooking's existing (already staffId-aware) validation pipeline; no new domain logic was needed.
  • Owner Appointment Access (/owner/appointments/[id]) — view/update status, reschedule/reassign, apply a loyalty reward, book on behalf of a customer, view customer notes — via owner-specific authorization helpers, kept separate from (not weakening) barber authorization.
  • Business Details (/owner/settings/business) — name/logo/description/address/contact/social links/timezone/currency/locale/policies, backed by Business/BusinessLocation/BusinessSettings.
  • Opening Hours (/owner/settings/hours) — per-weekday enable/times, "copy Monday to all days", conflict review before saving (shows affected future appointments, never auto-cancels, requires explicit "Save Anyway").
  • Staff Management (/owner/team, /owner/team/[id]) — search/add/view/deactivate/reactivate, role assignment, service eligibility, working-hours edit-on-behalf-of. Deactivation is conflict-reviewed and last-owner-protected.
  • Add Staff / Invitations (/owner/team/add, /invite/accept) — creates a fully usable staff profile immediately, plus a StaffInvitation row surfaced as a dev-safe copy-link (no real email delivery this phase).
  • Roles and Permissions — fixed built-in Owner/Barber roles (schema supports more); last-active-owner protected against self-removal/self-deactivation.
  • Staff Service Eligibility — per-staff checklist of active services, conflict-reviewed when removing a service with future bookings.
  • Service Categories and Services (/owner/services) — create/rename/reorder (up/down buttons, not drag-and-drop)/activate-deactivate/delete-if-unused, assign eligible staff, configure online-booking/buffers. Money handled as strings end-to-end, never a JS number.
  • Booking Rules (/owner/settings/booking-rules) — minimum notice, max advance window, cancellation/reschedule windows, slot interval, buffers, per-day and per-staff-per-day caps, guest booking, barber selection, approval requirement.
  • Loyalty Configuration (/owner/settings/loyalty) — enable/disable, stamps required, reward type (percentage/fixed/free-service) and value, eligible services/staff, expiry, auto-apply, card appearance, terms.
  • Loyalty Reward Redemption (new this phase — Phase 5 deliberately left it unbuilt) — apply an AVAILABLE reward to an eligible appointment, atomically marking it REDEEMED, deducting stamps, and recording a discount on the appointment; available to both Owner and Barber (it's an operational action, not owner-only configuration).
  • Business Settings — currency/locale/timezone/policies via the existing structured BusinessSettings model (no unstructured JSON blob).
  • Owner Settings Hub (/owner/settings) — links to all the above, plus "Switch to Barber Tools" and the existing personal notification-preferences page (reused, not duplicated).
  • Audit Logging — staff added/deactivated/role-changed/services-changed, business profile/hours changed, booking rules changed, loyalty config changed, reward redeemed, appointment reassigned — all written to the pre-existing (previously-unused) AuditLog table.

2. Routes created

Inside (owner) (persistent nav via the shared StaffShell, parametrized with OWNER_NAV_ITEMS): /owner, /owner/calendar, /owner/appointments/[id], /owner/team, /owner/team/add, /owner/team/[id], /owner/team/[id]/hours, /owner/services, /owner/settings, /owner/settings/business, /owner/settings/hours, /owner/settings/booking-rules, /owner/settings/loyalty.

Outside (owner) (own fixed-bottom-bar UI, same route-group-splitting pattern Phase 4/5 established for /book and /staff/appointments/[id]/reschedule): /owner/appointments/[id]/reschedule.

Public (inside (auth) for its shared centered-card shell, not staff/owner-gated): /invite/accept.

3. Components

New, owner-scoped (src/features/owner/components/): owner-block-time-button.tsx, redeem-reward-button.tsx, business-details-form.tsx, opening-hours-form.tsx, add-staff-form.tsx, staff-detail-panel.tsx, catalogue-manager.tsx, service-form-sheet.tsx, booking-rules-form.tsx, loyalty-config-form.tsx.

New, shared auth (src/features/auth/components/accept-invitation-form.tsx).

Generalized, not forked (the same dependency-injection pattern Phase 5 used for ServiceSelector.onContinue):

  • AppointmentStatusActions gained an optional actions?: AppointmentActionSet prop (default = staff Server Actions); the owner appointment page injects the owner variant instead of forking the component.
  • WorkingHoursForm gained an optional onSave prop so /owner/team/[id]/hours injects updateStaffWorkingHoursAction instead of the self-service action.
  • DayCalendar gained an optional appointmentHrefBase prop so the same component links to /owner/appointments/[id] or /staff/appointments/[id] depending on context.
  • DateStrip gained an optional extraParams prop so the owner calendar's ?staffId= filter survives date navigation.
  • StaffShell/StaffSidebarNav/BottomNav all gained an items prop (default STAFF_NAV_ITEMS); the owner layout passes OWNER_NAV_ITEMS instead. StaffSidebarNav additionally gained an optional footerLink prop for the owner's "Barber Tools" back-link — explicitly one shared nav component, not competing owner/staff variants.

4. Domain services

New:

  • BusinessService (src/services/business/business.service.ts) — business profile/location/settings CRUD, opening-hours validation and conflict-checked read/write. Deliberately excludes booking rules (scheduling policy) and loyalty (its own service) — see its own file header.
  • ServiceCatalogueService (src/services/catalogue/service-catalogue.service.ts) — categories and services CRUD, reordering, usage-conflict lookup. Staff eligibility stays in StaffService (it's staff-scoped data, not catalogue data).
  • booking-rule.service.ts (src/services/scheduling/booking-rule.service.ts) — read/write and validation for the BookingRule row itself. A new sibling file rather than an addition to business-rules.service.ts, because that file is deliberately pure/Prisma-free (its own header says so) and needs to stay that way to remain cheap to unit test; this file owns the one thing that one doesn't — loading and persisting the configuration an owner actually edits.
  • OwnerReportingService (src/services/reporting/owner-reporting.service.ts) — read-only dashboard aggregation (getDashboardSummary, getConfigurationWarnings). Exists only because the dashboard's queries are genuinely more than "call one existing service and render it"; not a general owner data-access layer, and no other feature imports from it.
  • StaffInvitationService (src/services/staff/staff-invitation.service.ts) — invitation token generation/validation/acceptance. Kept out of StaffService deliberately (acceptance tracking is a distinct concern from the staff profile itself, which is fully usable the moment it's created).

Extended:

  • StaffService — owner-facing additions: listStaffForBusiness, getStaffDetailById, listRolesForBusiness, createStaffProfile, updateStaffAsOwner, deactivateStaff/reactivateStaff (both last-owner-protected), setStaffServiceEligibility, all with matching conflict-finder functions (findStaffDeactivationConflicts, findServiceEligibilityConflicts).
  • LoyaltyService — programme configuration (getProgrammeConfig, updateProgrammeConfig, setEligibleServices, setEligibleStaff) and reward redemption (listEligibleAppointmentsForReward, redeemReward).

Reused as-is: BookingService.rescheduleBooking (for reassignment), AppointmentService, AvailabilityService, StaffAvailabilityService, BusinessRulesService (evaluation logic only), CustomerService.

New (feature-layer, not domain services): src/features/owner/authorization.ts (assertOwnerPermission, mirroring src/features/staff/authorization.ts), src/lib/audit/write-audit-log.ts.

5. Schema changes

Two migrations:

20260803070350_owner_administration:

  • Permission enum gained MANAGE_ALL_APPOINTMENTS and VIEW_REPORTING.
  • Appointment.discountAmount Decimal? @db.Decimal(10,2) — the effect of a redeemed loyalty reward, kept separate from priceAtBooking/durationAtBookingMinutes snapshots so those never need to change.
  • New StaffInvitation model (token, email, expiresAt, acceptedAt, invitedByUserId) — one per StaffProfile, opaque DB-validated token (not HMAC-signed like the booking-draft cookie, since it's a single-use server-validated lookup, not something a client needs to verify offline).
  • LoyaltyReward gained a real FK for appliedToAppointment (was already a plain string column) plus redeemedByStaffId/redeemedByStaff.

20260803071240_loyalty_free_service:

  • LoyaltyProgramme.freeServiceId/freeService — which service is free for a FREE_SERVICE reward. A genuine schema gap: the existing eligibleServices list serves a different purpose (which services earn stamps), not "which service the reward covers."

Both applied via npx prisma migrate dev against a real Postgres, then npx prisma generate; seed and tests updated to match.

Reviewed and deliberately not added: a Business.isPubliclyVisible toggle (no consumer/enforcement point yet in a single-business app), a dedicated "business exceptional closures" table (the brief said "where appropriate"; opening-hours conflict review already covers the common case).

6. Dashboard metric approach

getDashboardSummary composes, for a given date: listAppointments (existing service) filtered/counted by status for the totals row; WorkingHours/TimeOff for "staff working today" vs "staff on time off"; occupancy as bookedMinutes / availableMinutes across staff actually working that day, rounded to a percent and always rendered with the label "an estimate, not a financial figure" — never a revenue figure, since the brief explicitly warned against inventing one. Popular services aggregates AppointmentService rows for COMPLETED appointments in the last 30 days, in memory (not a SQL GROUP BY) since the dataset size doesn't warrant it. Configuration warnings (getConfigurationWarnings) are a separate, independently-callable function: no opening hours at all, a staff member with no working day set, an active service with no active eligible staff, an enabled-but-incomplete loyalty programme (reusing validateProgrammeConfig rather than re-implementing the check), and upcoming time-off that overlaps an existing appointment.

7. All-staff calendar approach

The owner calendar page is new, but it renders zero new calendar UI: it reuses DayCalendar (one column per staff member, or a single column when filtered) and AppointmentCard (agenda view) exactly as the barber calendar does, just fetching listAppointments for the whole business instead of one staff member and looping columns. The staff filter and Day/Agenda toggle are plain <Link>s carrying query params (?date=&view=&staffId=), so the page stays a Server Component with no client-side calendar state to keep in sync. "New" links straight to /staff/book (already lets any signed-in staff member, owner included, pick any eligible barber) rather than a duplicate /owner/book flow.

8. Business/opening-hours behaviour

setOpeningHours validates structurally first (open day needs both times, times within a single day, open before close), then — unless the caller passes acknowledgeConflicts — calls findOpeningHoursConflicts, which finds future, non-cancelled appointments that would fall outside the proposed hours (either on a day newly marked closed, or outside a still-open day's new window). If any exist, the save is rejected with the conflict list attached; the UI shows each affected appointment and offers "Go Back" or "Save Anyway." Saving never touches the appointments themselves — only BusinessHours rows, in one transaction, in an upsert per weekday. The customer-facing availability engine reads BusinessHours fresh on every request, so a save is reflected immediately with no cache to invalidate.

9. Staff invitation/management approach

createStaffProfile creates a fully usable User + StaffProfile + initial StaffService eligibility rows in one nested create — the profile can appear on the calendar and be booked immediately, it is never gated on invitation acceptance. StaffInvitationService.createInvitation separately creates a StaffInvitation row with a random 32-byte base64url token and a 7-day expiry; the owner UI surfaces it as a copy-link (/invite/accept?token=...) since there's no real email delivery this phase — documented as a deliberate dev-safe shortcut, not a production gap to hide. Accepting sets a password (bcrypt-hashed, mirroring registerAction), marks emailVerified/acceptedAt, and signs the new staff member in immediately. Deactivation (deactivateStaff) is conflict-reviewed exactly like opening hours (future appointments shown, "Deactivate Anyway" required) and last-owner-protected (see §14); it flips StaffProfile.status to INACTIVE and never deletes the row, preserving appointment history integrity (Appointment.staff is onDelete: Restrict for exactly this reason).

10. Service/category management approach

Categories and services share one conflict-review-free CRUD path for create/edit/reorder/activate-deactivate (these can never orphan an existing booking — a deactivated service or category simply stops appearing for new bookings), but deletion is different: deleteCategory refuses outright if the category still has any services (active or not), directing the owner to deactivate instead — deletion is only for genuinely empty, unused categories. Editing a service's price or duration only ever changes the live Service row; the historical AppointmentService.priceAtBooking/durationAtBookingMinutes snapshot on every past and already-confirmed appointment is never rewritten (verified by a dedicated integration test). Reordering is up/down buttons per item, not drag-and-drop, matching the brief's own accessibility note about keyboard alternatives.

11. Booking-rule behaviour

updateBookingRule validates every field's range (minimum notice ≥ 0, max advance ≥ 1 day, slot interval 5–120 minutes, per-day/per-staff-per-day caps ≥ 1 or unlimited) and upserts the single BookingRule row for the business. No conflict-review step exists for booking rules specifically — tightening a rule (e.g. requiring approval, shortening the cancellation window) changes how future bookings behave without altering any existing appointment's stored state, so there is nothing to show an affected-appointments list for; the customer booking flow reads the current BookingRule row fresh on every booking attempt, so a save takes effect on the very next booking (exercised directly in the E2E "require approval" journey, §16).

12. Loyalty configuration/redemption approach

updateProgrammeConfig validates only what's required when enabled (validateProgrammeConfig) — a disabled, half-configured draft is allowed since the owner may still be working on it; enabling a FREE_SERVICE reward additionally checks the chosen service is active. setEligibleServices/setEligibleStaff replace their respective lists wholesale in a transaction (delete-all-then-recreate), matching the same pattern setStaffServiceEligibility uses.

redeemReward is the one place LoyaltyReward.status, LoyaltyAccount.currentStamps, and Appointment.discountAmount ever change together, inside a single $transaction. The status flip uses a conditional updateMany (WHERE status = 'AVAILABLE') rather than a read-then-write, so two simultaneous redemption attempts can't both succeed — the loser sees REWARD_NOT_AVAILABLE, never a double deduction (covered by a dedicated integration test that redeems, then attempts a second redemption of the same reward). The discount is computed from the reward type (percentage of subtotal, fixed amount, or the price of the matching free-service line), clamped to [0, subtotal], and written to Appointment.discountAmount — the original priceAtBooking snapshots are never touched, so the subtotal is always recoverable and the final total is always subtotal - discountAmount, never negative. A documented simplification: redemption reads the loyalty programme's current stampsRequired/freeServiceId, not a value snapshotted when the reward was earned — if the owner reconfigures the programme in between, redemption reflects the new configuration.

13. Conflict-review behaviour

One pattern, reused everywhere a change could strand an existing appointment: validate → find affected future appointments → if any and not yet acknowledged, return them without writing anything → the UI shows each one and offers "Go Back" or an explicit "[Verb] Anyway" → only that second call actually writes, and only ever the configuration being changed, never the appointments themselves → the same call also writes an audit log entry. This exact shape now covers business opening hours (§8), staff deactivation (§9), and staff service-eligibility removal (§10) — all three share the "conflicts: []" / "acknowledgeConflicts" parameter shape and all three are exercised by an integration test asserting the blocked-then-Save-Anyway-then-appointment-still-intact sequence. Booking rules (§11) and loyalty configuration (§12) don't need this pattern, for the reasons given in their own sections.

14. Permission and business-isolation rules

The (owner) layout gates on MANAGE_STAFF as a coarse "is this person any kind of owner" shell check — but every individual Server Action re-derives the session and checks the specific permission that action needs (MANAGE_BUSINESS_PROFILE, MANAGE_SERVICES, MANAGE_BOOKING_RULES, MANAGE_LOYALTY_PROGRAMME, MANAGE_ALL_APPOINTMENTS, VIEW_ALL_STAFF_APPOINTMENTS, VIEW_REPORTING), via assertOwnerPermission → hasPermission, which re-queries the database every time rather than trusting the session/JWT — a permission change (or a demotion) takes effect on the very next request, not after the token expires (verified directly: see the integration test asserting a permission change is reflected immediately, §16). Every owner service function additionally re-scopes its query by businessId even though this is a single-business v1 app — explicit defense-in-depth, not dead code, matching the same discipline Phase 4/5 applied. Last-owner protection: "an owner" is defined as any staff member whose role holds MANAGE_STAFF (not a hardcoded role name, so a future custom role with that permission is automatically covered); both deactivateStaff and updateStaffAsOwner (role change) refuse the action if it would leave zero other active staff members holding MANAGE_STAFF.

15. Audit logging

writeAuditLog(prisma, {actorUserId, action, entityType, entityId, metadata}) is called from every owner mutation Server Action (not from inside the domain services themselves, keeping the domain layer free of "who did this and why" concerns) with an action-specific name: BUSINESS_PROFILE_UPDATED, STAFF_ADDED, STAFF_ROLE_CHANGED/STAFF_UPDATED, STAFF_DEACTIVATED/STAFF_REACTIVATED, STAFF_SERVICES_CHANGED, STAFF_WORKING_HOURS_CHANGED, BOOKING_RULES_UPDATED, LOYALTY_CONFIG_UPDATED, LOYALTY_REWARD_REDEEMED, APPOINTMENT_REASSIGNED (only written when the staff actually changes, not on a plain reschedule). metadata is typed Prisma.InputJsonValue and only ever carries safe, already-public-to-the-owner values (names, ids, booleans) — no passwords, invitation tokens, or other secrets are ever passed in.

16. Tests

Unit (Vitest, no DB — 179 tests total, up from ~150 before this phase): pure validators for every new input shape — validateBusinessProfileInput/validateOpeningHoursInput (business.service.test.ts), validateCategoryInput/validateServiceInput including the decimal-price regex (service-catalogue.service.test.ts), validateBookingRuleInput including every range boundary (booking-rule.service.test.ts), validateProgrammeConfig for all three reward types and the enabled-vs-disabled leniency (loyalty-config.test.ts), and validateCreateStaffInput (appended to the existing staff.service.test.ts).

Integration (Vitest, real throwaway Postgres — 86 tests total): business.service.integration.test.ts (opening-hours conflict detection + Save Anyway + the appointment stays untouched), service-catalogue.service.integration.test.ts (category-deletion-blocked-when-has-services, business isolation, and the historical-snapshot-preservation test described in §10), staff-owner.integration.test.ts (staff creation + duplicate-email rejection, last-owner protection on both deactivation and role change, deactivation/eligibility conflict review + Save Anyway), loyalty-owner.integration.test.ts (programme config create-then-update, free-service-must-be-active, eligible-services replace-wholesale, and the redemption suite: successful redemption's atomic discount/status/stamp effects, duplicate-redemption race prevention via the conditional updateMany, already-discounted-appointment rejection, wrong-customer rejection), and authorization.integration.test.ts (owner-vs-barber permission grants/denials against real Role/RolePermission rows, a live permission-change-takes-effect-immediately test, and business-isolation checks on updateStaffAsOwner/deactivateStaff/deleteCategory against a second business's data).

End-to-end (Playwright, e2e/owner-journeys.spec.ts, 12 journeys, run serially): dashboard with real data and configuration warnings; all-staff calendar filter + Day/Agenda; business details edit + persistence; opening hours tightened + conflict shown + Save Anyway + appointment untouched; add a barber and get a dev invite link; change a barber's role and service eligibility (round-tripped); create a category and service, verified immediately on the customer /book page; require booking approval and verify a fresh customer booking lands Pending Approval; configure the loyalty programme; redeem a customer's available reward; a barber without MANAGE_STAFF is redirected away from /owner; the owner switches back to Barber Tools without losing the barber nav. Every journey that mutates shared configuration (business details, opening hours, booking rules, loyalty config) restores the original value at the end of the same test, matching the Phase 5 precedent; journey 10 (redeem reward) is a documented one-shot consumption of the seed's single AVAILABLE reward, same category as Phase 5's own booking journeys.

Running them:

npm run test              # unit, no DB — 179 tests
npm run test:integration  # needs DATABASE_URL → a real Postgres — 86 tests
npm run test:e2e          # needs DATABASE_URL → a real (throwaway) Postgres — 27 journeys (6 Phase 4 + 9 Phase 5 + 12 Phase 6)

17. Manual verification

  • Signed in as the owner (Marcus Reed) and walked every new page — dashboard, all-staff calendar (day + agenda, staff filter), business details, opening hours, team list + staff detail (role/eligibility/deactivate), add-staff + invite-link, services & categories (add/edit/reorder/delete-blocked), booking rules, loyalty config, appointment details + redeem reward, reschedule/reassign — in a real browser against seeded data, at both mobile (375px) and desktop (1280px) viewports.
  • Signed in as a barber (Leo Vance, no MANAGE_STAFF) and confirmed /owner redirects to /staff with no owner nav visible.
  • Verified the desktop two-column treatment on Business Details, Booking Rules, and Loyalty Configuration (element rects confirmed genuinely side-by-side, not just visually similar in a screenshot), the Team page's 2-column card grid, the Staff Detail page's master-detail split, and the all-staff calendar's multi-column day view — and confirmed all of them still stack to a single column at 375px with no regression.
  • npx tsc --noEmit, npx eslint ., and npx next build all clean on the final state.
  • All three automated suites (179 unit / 86 integration / 27 E2E) passed together on one freshly created, migrated, and seeded database in a single run — the authoritative verification (see §19 for why a repeated run against the same database needs care).

18. Assumptions made

  • A single-business deployment, same as every prior phase — no multi-business owner switching was built or implied.
  • "An owner" is a permission, not an account type — Owner/Barber are the only two roles seeded, but every last-owner check is written against "does this role hold MANAGE_STAFF," so a future Manager/Receptionist role with a subset of owner permissions fits without a schema or logic change.
  • Redemption eligibility is service-agnostic beyond FREE_SERVICE — a percentage or fixed-amount reward can be applied to any of the customer's own appointments, not restricted to appointments containing "eligible" services (eligibility governs earning stamps, not which appointment a reward can be applied to).
  • No payments/deposits are modelled — a redeemed reward's discount is recorded, but there is no payment capture, refund, or deposit-hold anywhere in this phase, matching the explicit scope boundary.
  • The dev invite-link flow is accepted as a permanent, documented dev-mode behaviour for this phase, not a stopgap hidden from the summary — real delivery is explicitly out of scope (§20).

19. Known limitations

  • Working-hours editing had no conflict detection (both the self-service and owner-on-behalf editors) — closed in Phase 7 ("Working-hours conflict review": findWorkingHoursConflicts / setWorkingHours({ acknowledgeConflicts }) in staff.service.ts, the conflict list in working-hours-form.tsx, covered by staff.service.integration.test.ts). Kept here only as history.
  • Owner-editable breaks/whole-day time off for a staff member were not built — resolved. Owner-on-behalf-of editing of a staff member's breaks and whole-day time off was built in Phase 7 (Manage → Team → staff → Breaks / Time Off). The one remaining piece, editing and deleting partial-day "Block Time" entries (which OwnerBlockTimeButton creates), now also works: they are listed on the same Time Off page, for the owner (MANAGE_STAFF) and for staff on their own Time Off settings page, using the same update/delete actions and conflict review.
  • Staff service eligibility is edited only from the Staff Detail page, not duplicated on the Services & Categories page (the brief mentions "assign eligible staff" under Services too) — one edit surface, avoiding two places that could drift out of sync.
  • "Requires staff approval to redeem" and "Apply reward automatically" were removed from the loyalty settings form — neither did anything (redemption is always staff-initiated and gated by MANAGE_LOYALTY_STAMPS). The two LoyaltyProgramme columns still exist, unused.
  • A repeated run of the E2E suite against the same (non-reseeded) database is not fully idempotent: journey 10 (redeem reward) consumes the seed's one AVAILABLE reward, and one seed appointment (appt-today-checkedin, timed via minutesFromNow to reliably exercise selectCurrentAppointment's CHECKED_IN tier) can occasionally start a few minutes before the business's 10:00 opening depending on exactly when the seed happens to run, which the opening-hours conflict test now handles defensively rather than assuming a clean save — this is a real, if narrow, characteristic of time-relative seed fixtures re-run without a fresh database, same category of issue Phase 5's own summary flagged (§13 there); it does not affect a single authoritative run against a freshly seeded database, which is how CI should always run it.
  • Notification delivery, payments, deposits, advanced financial reporting, payroll, multi-business/multi-location, PWA, and offline remain entirely out of scope, per the brief's own boundary.

20. Review items for Phase 7

  • Decide whether owner-editable breaks and whole-day time off (on behalf of staff) should be built — built in Phase 7; Block Time edit/delete followed later (see §19).
  • Decide whether working-hours edits (both self-service and owner-on-behalf-of) should gain the same conflict-review treatment as breaks/time-off/business-hours, closing the gap flagged in §19.
  • Real notification delivery (email/SMS/push) is still entirely unbuilt — both the Phase 5 preference grid and this phase's invite-link workaround are waiting on it.
  • Payments/deposits and any real financial reporting beyond the operational dashboard estimate are unstarted; Appointment.discountAmount exists and is populated correctly, but nothing yet reconciles it against an actual payment.
  • If a future phase adds custom (non-Owner/Barber) roles with a role editor, the "is an owner" check (role holds MANAGE_STAFF) and the fixed built-in-roles assumption in listRolesForBusiness/AddStaffForm should be revisited together.
  • The E2E suite's reliance on a freshly seeded database for a fully clean run (§19) has no CI wiring yet to provision one automatically — same open item Phase 4/5 already flagged, still unresolved.