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.
This commit is contained in:
Stefan Lazic committed 2026-08-02 21:39:24 +01:00
1 parent 8b63cc66a9
commit 6ae145d187
66 files changed
+4599 -107

No files matched your search

+183 -5
View File
@@ -8,6 +8,17 @@ import { PrismaClient, Permission, Weekday, RewardType } from "../generated/pris
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 });
@@ -129,7 +140,17 @@ async function main() {
},
});
const [haircut, skinfade] = await Promise.all([
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: {},
@@ -154,6 +175,18 @@ async function main() {
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);
@@ -178,7 +211,18 @@ async function main() {
})),
},
services: {
create: [{ serviceId: haircut.id }, { serviceId: skinfade.id }],
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 })) },
},
],
},
},
},
@@ -187,7 +231,7 @@ async function main() {
});
const barberPasswordHash = await bcrypt.hash("password123", 12);
await prisma.user.upsert({
const barberUser = await prisma.user.upsert({
where: { email: "leo@blakeheritage.co.uk" },
update: {},
create: {
@@ -208,13 +252,59 @@ async function main() {
})),
},
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);
await prisma.user.upsert({
const registeredCustomerUser = await prisma.user.upsert({
where: { email: "alex.chen@example.com" },
update: {},
create: {
@@ -227,17 +317,105 @@ async function main() {
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],
services: [haircut.name, skinfade.name, beardTrim.name],
staff: [ownerUser.name, barberUser.name, secondBarberUser.name],
customers: [registeredCustomerUser.name, `${guestCustomer.firstName} ${guestCustomer.lastName} (guest)`],
});
}