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
+236
@@ -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],
|
||||
|
||||
Reference in new issue
Block a user