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

@@ -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;
@@ -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;
+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())
+236
View File
@@ -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<void> {
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],