Seed data extended with a second barber (different hours/services), a beard-trim service + category, breaks, time off, a guest customer, and appointments in all three key states — verified against a live throwaway Postgres. 14 new integration tests (38 total with Phase 3's) covering guest/authenticated booking, any-barber assignment, slot-conflict handling, ownership enforcement, guest-data isolation, and book-again repricing. Playwright E2E suite for the 6 required customer journeys, with its own global-setup.ts that migrates and seeds a database automatically. Full verification pass, which caught and fixed three real bugs along the way (not just test-workarounds): A date off-by-one bug in the calendar picker that made real weekdays show as closed for anyone in a UTC+ timezone (including the business's own). A dialog that could vanish mid-confirmation on cancellation, before the customer could read the late-cancellation notice. A silent test-cleanup leak in the Phase 3 fixtures that had been leaking stray businesses since Phase 3. Plus a baseline desktop-responsive fix (centered max-w-2xl columns) across Home/Appointments/Settings/booking flow, since desktop had been effectively unstyled. Final state: 90 unit tests, 38 integration tests, and all 6 E2E journeys passing together on a fresh database; clean tsc, eslint, and next build. Full writeup with routes, security model, guest-matching rules, known limitations, and what to check before Phase 5 is in docs/phase-4-summary.md.
430 lines
14 KiB
TypeScript
430 lines
14 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, Permission, Weekday, RewardType } from "../generated/prisma/client";
|
|
import { PrismaPg } from "@prisma/adapter-pg";
|
|
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;
|
|
}
|
|
|
|
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
|
const prisma = new PrismaClient({ adapter });
|
|
|
|
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_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 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 })) },
|
|
},
|
|
});
|
|
|
|
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 })) },
|
|
},
|
|
});
|
|
|
|
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",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
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,
|
|
},
|
|
});
|
|
|
|
const registeredCustomer = registeredCustomerUser.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 },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
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, `${guestCustomer.firstName} ${guestCustomer.lastName} (guest)`],
|
|
});
|
|
}
|
|
|
|
main()
|
|
.catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
})
|
|
.finally(async () => {
|
|
await prisma.$disconnect();
|
|
});
|