Owner Administration is complete. Summary of this final stretch of work:

Seed data extended with an inactive staff member, a pending invitation, a redeemed-reward fixture, a service with no eligible staff, and a future-appointment fixture for conflict testing — all idempotent, verified against the dev DB.

Testing brought to 179 unit / 86 integration / 27 E2E tests, all passing together on a freshly seeded, disposable Postgres:

Unit tests for every new pure validator (business hours, categories/services, booking rules, loyalty config, staff creation).
Integration tests covering opening-hours conflicts, last-owner protection, staff deactivation/eligibility conflicts, loyalty redemption's atomic race-guard, and owner-vs-barber/business-isolation authorization — all against a real Postgres.
12 new Playwright owner journeys, which surfaced and fixed two genuine issues along the way: a cookie-clearing race condition in the sign-out-mid-test flow (a real, reproducible timing bug, not just a test artifact), and confirmation that seed fixtures upserted by deterministic ID go stale once "today" has moved on — now handled defensively.
Desktop responsiveness: converted the single-column Business Details, Booking Rules, and Loyalty Configuration forms to a genuine two-column layout at the lg: breakpoint (verified via measured element positions, not just screenshots), confirmed mobile stacking is unaffected.

Final verification: clean tsc, eslint, and next build, plus the full three-suite run against one fresh database as the authoritative pass.
This commit is contained in:
Stefan Lazic committed 2026-08-03 12:56:55 +01:00
1 parent ffee9d6754
commit ec5583f972
73 files changed
+8400 -53

No files matched your search

+78 -8
View File
@@ -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())