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
@@ -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
@@ -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
@@ -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