/** * Dev/demo fixtures. Run with `npm run prisma:seed` (requires a reachable * DATABASE_URL — see .env.example). Intentionally small: enough to exercise * every relation in the schema once, not a full demo dataset. */ import "dotenv/config"; import { PrismaClient, Permission, Weekday, RewardType, NotificationType } from "../generated/prisma/client"; import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config"; import { notifySafely } from "../src/services/notifications/notification.service"; import bcrypt from "bcryptjs"; /** * All dates below are computed relative to "now" so the seed stays useful * (an upcoming appointment stays upcoming) no matter when it's re-run. */ function daysFromNow(days: number, hour: number, minute = 0): Date { const date = new Date(); date.setDate(date.getDate() + days); date.setHours(hour, minute, 0, 0); return date; } /** For appointments that need to be genuinely "in progress" / "starting soon" right now, regardless of what time the seed happens to run — used to exercise Phase 5's Current Appointment selection tiers reliably. */ function minutesFromNow(minutes: number): Date { return new Date(Date.now() + minutes * 60_000); } const adapter = new PrismaMariaDb(buildMysqlPoolConfig(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 { 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, Permission.MANAGE_SERVICES, Permission.MANAGE_STAFF, 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[] = [ 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, ]; const WEEKDAYS: Weekday[] = [ Weekday.MONDAY, Weekday.TUESDAY, Weekday.WEDNESDAY, Weekday.THURSDAY, Weekday.FRIDAY, Weekday.SATURDAY, ]; async function main() { const business = await prisma.business.upsert({ where: { slug: "blake-heritage-barbershop" }, update: {}, create: { slug: "blake-heritage-barbershop", name: "Blake Heritage Barbershop", description: "Blake Heritage has come to Northwood! Check our Instagram @Blake.Heritage.Barbershop for more info.", phone: "+44 1923 000000", email: "hello@blakeheritage.co.uk", timezone: "Europe/London", currency: "GBP", locale: "en-GB", locations: { create: { addressLine1: "79 Joel Street", city: "Northwood", postalCode: "HA6 1LW", country: "GB", }, }, settings: { create: { cancellationPolicy: "Free cancellation up to 24 hours before your appointment.", lateArrivalPolicy: "Arrivals more than 10 minutes late may need to be rescheduled.", }, }, bookingRule: { create: {} }, // all defaults from the schema hours: { create: [ ...WEEKDAYS.map((dayOfWeek) => ({ dayOfWeek, opensAtMinutes: 10 * 60, closesAtMinutes: 19 * 60, })), { dayOfWeek: Weekday.SUNDAY, isClosed: true }, ], }, loyaltyProgramme: { create: { isEnabled: true, stampsRequired: 10, rewardType: RewardType.PERCENTAGE, rewardValue: 50, rewardLabel: "50% Off Any Service", termsLine: "Valid for 12 months. One reward per visit.", }, }, }, }); const ownerRole = await prisma.role.upsert({ where: { businessId_name: { businessId: business.id, name: "Owner" } }, update: {}, create: { businessId: business.id, name: "Owner", isSystem: true, 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" } }, update: {}, create: { businessId: business.id, name: "Barber", isSystem: true, 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` }, update: {}, create: { id: `${business.id}-senior-barber`, businessId: business.id, name: "Hair & Beard Services — Senior Barber", }, }); const beardCategory = await prisma.serviceCategory.upsert({ where: { id: `${business.id}-beard-services` }, update: {}, create: { id: `${business.id}-beard-services`, businessId: business.id, name: "Beard Services", }, }); const [haircut, skinfade, beardTrim] = await Promise.all([ prisma.service.upsert({ where: { id: `${business.id}-haircut-style` }, update: {}, create: { id: `${business.id}-haircut-style`, businessId: business.id, categoryId: category.id, name: "Haircut & Style", durationMinutes: 30, price: 22, }, }), prisma.service.upsert({ where: { id: `${business.id}-skinfade-style` }, update: {}, create: { id: `${business.id}-skinfade-style`, businessId: business.id, categoryId: category.id, name: "Skinfade / Taper & Style", durationMinutes: 30, price: 25, }, }), prisma.service.upsert({ where: { id: `${business.id}-beard-trim-shape` }, update: {}, create: { id: `${business.id}-beard-trim-shape`, businessId: business.id, categoryId: beardCategory.id, name: "Beard Trim & Shape", durationMinutes: 20, price: 15, }, }), ]); const ownerPasswordHash = await bcrypt.hash("password123", 12); const ownerUser = await prisma.user.upsert({ where: { email: "marcus@blakeheritage.co.uk" }, update: {}, create: { email: "marcus@blakeheritage.co.uk", name: "Marcus Reed", passwordHash: ownerPasswordHash, emailVerified: new Date(), staffProfile: { create: { businessId: business.id, roleId: ownerRole.id, bio: "Master barber, owner.", workingHours: { create: WEEKDAYS.map((dayOfWeek) => ({ dayOfWeek, startMinutes: 10 * 60, endMinutes: 19 * 60, })), }, services: { create: [{ serviceId: haircut.id }, { serviceId: skinfade.id }, { serviceId: beardTrim.id }], }, // Daily lunch break, Mon-Sat — exercises the "unavailable slot mid-day" path. breaks: { create: [ { label: "Lunch", startMinutes: 13 * 60, endMinutes: 13 * 60 + 30, days: { create: WEEKDAYS.map((dayOfWeek) => ({ dayOfWeek })) }, }, ], }, }, }, }, include: { staffProfile: true }, }); const barberPasswordHash = await bcrypt.hash("password123", 12); const barberUser = await prisma.user.upsert({ where: { email: "leo@blakeheritage.co.uk" }, update: {}, create: { email: "leo@blakeheritage.co.uk", name: "Leo Vance", passwordHash: barberPasswordHash, emailVerified: new Date(), staffProfile: { create: { businessId: business.id, roleId: barberRole.id, bio: "Senior specialist.", workingHours: { create: WEEKDAYS.map((dayOfWeek) => ({ dayOfWeek, startMinutes: 10 * 60, endMinutes: 19 * 60, })), }, services: { create: [{ serviceId: haircut.id }] }, // A week of approved leave starting next week — exercises the "staff on time off" path. timeOff: { create: [ { startDate: daysFromNow(7, 0), endDate: daysFromNow(11, 0), reason: "Annual leave", }, ], }, // A short recurring break, distinct from Marcus's lunch — exercises // the Breaks editor with a second, differently-timed staff member. breaks: { create: [ { label: "Coffee break", startMinutes: 15 * 60, endMinutes: 15 * 60 + 15, days: { create: [{ dayOfWeek: Weekday.TUESDAY }, { dayOfWeek: Weekday.THURSDAY }] }, }, ], }, }, }, }, include: { staffProfile: true }, }); // Second barber: different days off (closed Sun+Mon rather than just Sun) // and a different service mix — exercises "not every barber can do every // service" and "any barber" resolution across staff with different rotas. const secondBarberPasswordHash = await bcrypt.hash("password123", 12); const secondBarberUser = await prisma.user.upsert({ where: { email: "jamie@blakeheritage.co.uk" }, update: {}, create: { email: "jamie@blakeheritage.co.uk", name: "Jamie Wallace", passwordHash: secondBarberPasswordHash, emailVerified: new Date(), staffProfile: { create: { businessId: business.id, roleId: barberRole.id, bio: "Fades and beard specialist.", workingHours: { create: [ { dayOfWeek: Weekday.TUESDAY, startMinutes: 9 * 60, endMinutes: 17 * 60 }, { dayOfWeek: Weekday.WEDNESDAY, startMinutes: 9 * 60, endMinutes: 17 * 60 }, { dayOfWeek: Weekday.THURSDAY, startMinutes: 9 * 60, endMinutes: 17 * 60 }, { dayOfWeek: Weekday.FRIDAY, startMinutes: 9 * 60, endMinutes: 17 * 60 }, { dayOfWeek: Weekday.SATURDAY, startMinutes: 9 * 60, endMinutes: 17 * 60 }, { dayOfWeek: Weekday.SUNDAY, isOff: true }, { dayOfWeek: Weekday.MONDAY, isOff: true }, ], }, services: { create: [{ serviceId: skinfade.id }, { serviceId: beardTrim.id }] }, }, }, }, include: { staffProfile: true }, }); const customerPasswordHash = await bcrypt.hash("password123", 12); const registeredCustomerUser = await prisma.user.upsert({ where: { email: "alex.chen@example.com" }, update: {}, create: { email: "alex.chen@example.com", name: "Alex Chen", passwordHash: customerPasswordHash, emailVerified: new Date(), customerProfile: { create: { firstName: "Alex", lastName: "Chen", email: "alex.chen@example.com", phone: "+44 7700 900123", isGuest: false, preferredBarberId: ownerUser.staffProfile?.id, }, }, }, include: { customerProfile: true }, }); // Guest customer — no linked User, matches the CustomerService guest-matching // rules (normalized email + exact phone) so re-running the booking flow with // these same details reuses this profile instead of creating a duplicate. const guestCustomer = await prisma.customerProfile.upsert({ where: { id: `${business.id}-guest-jordan-blake` }, update: {}, create: { id: `${business.id}-guest-jordan-blake`, firstName: "Jordan", lastName: "Blake", email: "jordan.blake@example.com", phone: "+44 7700 900456", isGuest: true, }, }); // A second registered customer, close to their loyalty reward threshold — // exercises the "available reward" indicator across the clients list, // customer details, and calendar. const secondCustomerPasswordHash = await bcrypt.hash("password123", 12); const secondCustomerUser = await prisma.user.upsert({ where: { email: "sam.osei@example.com" }, update: {}, create: { email: "sam.osei@example.com", name: "Sam Osei", passwordHash: secondCustomerPasswordHash, emailVerified: new Date(), customerProfile: { create: { firstName: "Sam", lastName: "Osei", email: "sam.osei@example.com", phone: "+44 7700 900789", isGuest: false, preferredBarberId: barberUser.staffProfile?.id, }, }, }, include: { customerProfile: true }, }); // A second guest customer, for clients-list search/sort variety. const secondGuestCustomer = await prisma.customerProfile.upsert({ where: { id: `${business.id}-guest-priya-kapoor` }, update: {}, create: { id: `${business.id}-guest-priya-kapoor`, firstName: "Priya", lastName: "Kapoor", email: "priya.kapoor@example.com", phone: "+44 7700 900321", isGuest: true, }, }); const registeredCustomer = registeredCustomerUser.customerProfile; const secondCustomer = secondCustomerUser.customerProfile; if (registeredCustomer && ownerUser.staffProfile && barberUser.staffProfile) { // Upcoming, confirmed — registered customer with the owner/barber. const upcomingStart = daysFromNow(3, 11, 0); await prisma.appointment.upsert({ where: { id: `${business.id}-appt-upcoming-confirmed` }, update: {}, create: { id: `${business.id}-appt-upcoming-confirmed`, businessId: business.id, customerId: registeredCustomer.id, staffId: ownerUser.staffProfile.id, status: "CONFIRMED", startAt: upcomingStart, endAt: new Date(upcomingStart.getTime() + haircut.durationMinutes * 60_000), services: { create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }], }, }, }); // Completed — a past visit, used to power "Book Again" and appointment history. const completedStart = daysFromNow(-14, 15, 0); await prisma.appointment.upsert({ where: { id: `${business.id}-appt-completed` }, update: {}, create: { id: `${business.id}-appt-completed`, businessId: business.id, customerId: registeredCustomer.id, staffId: barberUser.staffProfile.id, status: "COMPLETED", startAt: completedStart, endAt: new Date(completedStart.getTime() + haircut.durationMinutes * 60_000), services: { create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }], }, }, }); } if (secondBarberUser.staffProfile) { // Cancelled — guest customer, cancelled by the guest themselves (no // cancelledByUserId, matching the Phase 4 guest-cancellation capability). const cancelledStart = daysFromNow(5, 10, 0); await prisma.appointment.upsert({ where: { id: `${business.id}-appt-cancelled` }, update: {}, create: { id: `${business.id}-appt-cancelled`, businessId: business.id, customerId: guestCustomer.id, staffId: secondBarberUser.staffProfile.id, status: "CANCELLED", startAt: cancelledStart, endAt: new Date(cancelledStart.getTime() + (skinfade.durationMinutes + beardTrim.durationMinutes) * 60_000), cancellationReason: "Schedule conflict", cancelledAt: daysFromNow(-1, 9, 0), services: { create: [ { serviceId: skinfade.id, priceAtBooking: skinfade.price, durationAtBookingMinutes: skinfade.durationMinutes }, { serviceId: beardTrim.id, priceAtBooking: beardTrim.price, durationAtBookingMinutes: beardTrim.durationMinutes }, ], }, }, }); } // ── Phase 5: today's appointments across statuses ───────────────────── // Spread across all three staff so signing in as any of them shows a // populated Calendar/Current Appointment immediately. The IN_PROGRESS, // CHECKED_IN, and "scheduled now" (CONFIRMED) rows below are anchored to // the actual current time (minutesFromNow) rather than a fixed hour, so // each one reliably exercises a different tier of // AppointmentService.selectCurrentAppointment regardless of when the // seed is run. if (ownerUser.staffProfile && barberUser.staffProfile && secondBarberUser.staffProfile && secondCustomer) { const marcus = ownerUser.staffProfile; const leo = barberUser.staffProfile; const jamie = secondBarberUser.staffProfile; // Marcus: completed earlier today (tests loyalty stamp-on-completion). const marcusCompletedStart = daysFromNow(0, 9, 0); await prisma.appointment.upsert({ where: { id: `${business.id}-appt-today-completed` }, update: {}, create: { id: `${business.id}-appt-today-completed`, businessId: business.id, customerId: registeredCustomer!.id, staffId: marcus.id, status: "COMPLETED", startAt: marcusCompletedStart, endAt: new Date(marcusCompletedStart.getTime() + haircut.durationMinutes * 60_000), services: { create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }], }, }, }); // Marcus: no-show earlier today. const marcusNoShowStart = daysFromNow(0, 9, 45); await prisma.appointment.upsert({ where: { id: `${business.id}-appt-today-noshow` }, update: {}, create: { id: `${business.id}-appt-today-noshow`, businessId: business.id, customerId: guestCustomer.id, staffId: marcus.id, status: "NO_SHOW", startAt: marcusNoShowStart, endAt: new Date(marcusNoShowStart.getTime() + skinfade.durationMinutes * 60_000), services: { create: [{ serviceId: skinfade.id, priceAtBooking: skinfade.price, durationAtBookingMinutes: skinfade.durationMinutes }], }, }, }); // Marcus: IN_PROGRESS right now — Current Appointment tier 1. await prisma.appointment.upsert({ where: { id: `${business.id}-appt-today-inprogress` }, update: {}, create: { id: `${business.id}-appt-today-inprogress`, businessId: business.id, customerId: secondCustomer.id, staffId: marcus.id, status: "IN_PROGRESS", startAt: minutesFromNow(-10), endAt: minutesFromNow(20), notes: "Regular, wants it short on the sides.", services: { create: [{ serviceId: skinfade.id, priceAtBooking: skinfade.price, durationAtBookingMinutes: skinfade.durationMinutes }], }, }, }); // Marcus: confirmed later this evening — also a natural fixture for // testing the break/time-off "overlaps an appointment" conflict flow // (try adding a break or blocking time from 16:30-17:30 today). const marcusLaterStart = daysFromNow(0, 17, 0); await prisma.appointment.upsert({ where: { id: `${business.id}-appt-today-confirmed` }, update: {}, create: { id: `${business.id}-appt-today-confirmed`, businessId: business.id, customerId: secondGuestCustomer.id, staffId: marcus.id, status: "CONFIRMED", startAt: marcusLaterStart, endAt: new Date(marcusLaterStart.getTime() + haircut.durationMinutes * 60_000), services: { create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }], }, }, }); // Leo: checked in, about to start — Current Appointment tier 2. await prisma.appointment.upsert({ where: { id: `${business.id}-appt-today-checkedin` }, update: {}, create: { id: `${business.id}-appt-today-checkedin`, businessId: business.id, // Deliberately not Alex Chen — she's the customer e2e suite's own // "book again" fixture with Leo (see appt-completed above and // e2e/customer-journeys.spec.ts), and doubling up on that exact // customer/staff/service combo here would make her appointments // list match two cards instead of one. customerId: secondCustomer.id, staffId: leo.id, status: "CHECKED_IN", startAt: minutesFromNow(5), endAt: minutesFromNow(5 + haircut.durationMinutes), services: { create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }], }, }, }); // Leo: pending confirmation later today. const leoPendingStart = daysFromNow(0, 18, 0); await prisma.appointment.upsert({ where: { id: `${business.id}-appt-today-pending` }, update: {}, create: { id: `${business.id}-appt-today-pending`, businessId: business.id, customerId: secondCustomer.id, staffId: leo.id, status: "PENDING", startAt: leoPendingStart, endAt: new Date(leoPendingStart.getTime() + haircut.durationMinutes * 60_000), services: { create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }], }, }, }); // Jamie: confirmed and already underway by the clock, but never // checked in — Current Appointment tier 3 ("scheduled now"). await prisma.appointment.upsert({ where: { id: `${business.id}-appt-today-scheduled-now` }, update: {}, create: { id: `${business.id}-appt-today-scheduled-now`, businessId: business.id, customerId: secondGuestCustomer.id, staffId: jamie.id, status: "CONFIRMED", startAt: minutesFromNow(-5), endAt: minutesFromNow(25), services: { create: [{ serviceId: skinfade.id, priceAtBooking: skinfade.price, durationAtBookingMinutes: skinfade.durationMinutes }], }, }, }); // Jamie: cancelled earlier today, for status-variety in the calendar/agenda view. const jamieCancelledStart = daysFromNow(0, 14, 0); await prisma.appointment.upsert({ where: { id: `${business.id}-appt-today-cancelled` }, update: {}, create: { id: `${business.id}-appt-today-cancelled`, businessId: business.id, customerId: guestCustomer.id, staffId: jamie.id, status: "CANCELLED", startAt: jamieCancelledStart, endAt: new Date(jamieCancelledStart.getTime() + beardTrim.durationMinutes * 60_000), cancellationReason: "Customer rescheduled by phone", cancelledAt: minutesFromNow(-90), services: { create: [{ serviceId: beardTrim.id, priceAtBooking: beardTrim.price, durationAtBookingMinutes: beardTrim.durationMinutes }], }, }, }); // ── Loyalty: accounts, a stamp ledger, and one available reward ───── const loyaltyProgramme = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: business.id }, }); if (registeredCustomer) { const alexAccount = await prisma.loyaltyAccount.upsert({ where: { customerId_loyaltyProgrammeId: { customerId: registeredCustomer.id, loyaltyProgrammeId: loyaltyProgramme.id } }, update: {}, create: { customerId: registeredCustomer.id, loyaltyProgrammeId: loyaltyProgramme.id, currentStamps: 3 }, }); const alexStampCount = await prisma.loyaltyTransaction.count({ where: { loyaltyAccountId: alexAccount.id } }); if (alexStampCount === 0) { await prisma.loyaltyTransaction.createMany({ data: [ { loyaltyAccountId: alexAccount.id, type: "STAMP_ADDED", amount: 1, authoredByStaffId: leo.id, appointmentId: `${business.id}-appt-completed` }, { loyaltyAccountId: alexAccount.id, type: "STAMP_ADDED", amount: 1, authoredByStaffId: marcus.id }, { loyaltyAccountId: alexAccount.id, type: "STAMP_ADDED", amount: 1, authoredByStaffId: marcus.id }, ], }); } } // Sam: right at the reward threshold — exercises the "available reward" indicator end-to-end. const samAccount = await prisma.loyaltyAccount.upsert({ where: { customerId_loyaltyProgrammeId: { customerId: secondCustomer.id, loyaltyProgrammeId: loyaltyProgramme.id } }, update: {}, create: { customerId: secondCustomer.id, loyaltyProgrammeId: loyaltyProgramme.id, currentStamps: loyaltyProgramme.stampsRequired }, }); const samStampCount = await prisma.loyaltyTransaction.count({ where: { loyaltyAccountId: samAccount.id } }); if (samStampCount === 0) { await prisma.loyaltyTransaction.createMany({ data: Array.from({ length: loyaltyProgramme.stampsRequired }, () => ({ loyaltyAccountId: samAccount.id, type: "STAMP_ADDED" as const, amount: 1, authoredByStaffId: leo.id, })), }); await prisma.loyaltyReward.create({ data: { loyaltyAccountId: samAccount.id, rewardType: loyaltyProgramme.rewardType, value: loyaltyProgramme.rewardValue, label: loyaltyProgramme.rewardLabel ?? "Reward", status: "AVAILABLE", }, }); } // ── Customer notes — internal-only, never shown on customer routes ── if (registeredCustomer) { await prisma.customerNote.upsert({ where: { id: `${business.id}-note-alex-1` }, update: {}, create: { id: `${business.id}-note-alex-1`, customerId: registeredCustomer.id, authorStaffId: marcus.id, body: "Prefers scissor-over-comb, not clippers, on top. Sensitive skin around the neckline.", }, }); } await prisma.customerNote.upsert({ where: { id: `${business.id}-note-sam-1` }, update: {}, create: { id: `${business.id}-note-sam-1`, customerId: secondCustomer.id, authorStaffId: leo.id, body: "Always running a few minutes late — worth a courtesy reminder text.", }, }); } // ── 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 }, }, ], }); } } // ── Phase 7: notification centre fixtures ────────────────────────────── // Routed through the real notifySafely() rather than raw prisma.create() // calls, so these get the exact same channel-resolution/idempotency // behaviour a live booking would — a fresh checkout of this repo should // show a populated notification bell for each role without needing to // drive a real action first. Uses notifySafely (not notify) so a rerun // of the seed never throws on the idempotency-key collision. if (ownerUser.staffProfile && barberUser.staffProfile && registeredCustomer) { await notifySafely(prisma, { businessId: business.id, type: NotificationType.BOOKING_CONFIRMED, recipientUserId: registeredCustomerUser.id, recipientCustomerId: registeredCustomer.id, appointmentId: `${business.id}-appt-today-completed`, subject: "Booking confirmed", bodyText: `Your ${haircut.name} with ${ownerUser.name} is confirmed.`, deepLinkPath: "/appointments", idempotencyKey: `notify:booking-confirmed:${business.id}-appt-today-completed`, }); await notifySafely(prisma, { businessId: business.id, type: NotificationType.LOYALTY_STAMP_ADDED, recipientUserId: registeredCustomerUser.id, recipientCustomerId: registeredCustomer.id, subject: "You've earned a loyalty stamp", bodyText: "You've earned a loyalty stamp. Keep it up!", deepLinkPath: "/loyalty", idempotencyKey: `notify:loyalty-stamp-added:seed-${registeredCustomer.id}`, }); // Marked read immediately below — gives the notification centre a mix // of read/unread state out of the box, matching real usage instead of // "everything unread forever." await prisma.notification.updateMany({ where: { idempotencyKey: `notify:loyalty-stamp-added:seed-${registeredCustomer.id}` }, data: { readAt: daysFromNow(-1, 9) }, }); await notifySafely(prisma, { businessId: business.id, type: NotificationType.STAFF_NEW_APPOINTMENT, recipientUserId: barberUser.id, appointmentId: `${business.id}-appt-today-checkedin`, subject: "New appointment", bodyText: `${secondCustomerUser.name ?? "A customer"} booked ${haircut.name}.`, deepLinkPath: "/staff/current", idempotencyKey: `notify:staff-new-appointment:${business.id}-appt-today-checkedin`, }); await notifySafely(prisma, { businessId: business.id, type: NotificationType.OWNER_NEW_BOOKING, recipientUserId: ownerUser.id, appointmentId: `${business.id}-appt-today-checkedin`, subject: "New booking", bodyText: `${secondCustomerUser.name ?? "A customer"} booked ${haircut.name} with ${barberUser.name}.`, deepLinkPath: "/owner/calendar", idempotencyKey: `notify:owner-new-booking:${business.id}-appt-today-checkedin`, }); } console.log("Seed complete:", { business: business.name, roles: [ownerRole.name, barberRole.name], services: [haircut.name, skinfade.name, beardTrim.name], staff: [ownerUser.name, barberUser.name, secondBarberUser.name], customers: [ registeredCustomerUser.name, secondCustomerUser.name, `${guestCustomer.firstName} ${guestCustomer.lastName} (guest)`, `${secondGuestCustomer.firstName} ${secondGuestCustomer.lastName} (guest)`, ], }); } main() .catch((error) => { console.error(error); process.exitCode = 1; }) .finally(async () => { await prisma.$disconnect(); });