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

+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],