Phase 4
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:
66 files changed
+4599
-107
No files matched your search
+183
-5
@@ -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)`],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user