Files
NextChair/prisma/seed.ts
T
2026-08-04 09:57:58 +01:00

1023 lines
39 KiB
TypeScript

/**
* 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, Weekday, RewardType, NotificationType, InstallationStatus } 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 { OWNER_PERMISSIONS, BARBER_PERMISSIONS, syncRolePermissions } from "../src/lib/roles/default-permissions";
import { INSTALLATION_SINGLETON_ID } from "../src/lib/installation/installation-state";
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 });
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.",
},
},
// Guest booking defaults to false as of Phase 8 (see BookingRule in
// prisma/schema.prisma) — the dev/demo fixture explicitly opts back
// in so the guest-booking E2E journey and manual testing keep
// working; a real installed business starts with it off (see
// src/features/installation/installer.service.ts) until the owner
// enables it themselves.
bookingRule: { create: { allowGuestBooking: true } },
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(prisma, 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(prisma, 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`,
});
}
// Dev/demo fixtures bypass the installation wizard entirely (see
// src/features/installation/installer.service.ts for the real,
// production first-run path) — this upsert only exists so a freshly
// seeded dev/CI/E2E database is treated as "installed" by the routing
// gate in src/proxy.ts, exactly as if a real install had completed.
// Never run alongside a real installation: the two are mutually
// exclusive by construction (the installer's own transaction refuses
// to start once this row exists — see ApplicationInstallation's model
// header).
await prisma.applicationInstallation.upsert({
where: { id: INSTALLATION_SINGLETON_ID },
update: {},
create: {
id: INSTALLATION_SINGLETON_ID,
status: InstallationStatus.INSTALLED,
installedAt: new Date(),
installedVersion: "dev-seed",
initialAdminUserId: ownerUser.id,
primaryBusinessId: business.id,
metadata: { source: "prisma/seed.ts" },
},
});
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();
});