Phase 6
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:
73 files changed
+8400
-53
No files matched your search
+78
-8
@@ -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())
|
||||
|
||||
|
||||
Reference in new issue
Block a user