Files
NextChair/prisma/seed.ts
T
Stefan Lazic 973187dcc7
CI / ci (push) Canceled after 0s
Multi-administrator support
1. Summary of changes
The installer-created account is no longer special. "Owner" is now literally renamed Administrator and behaves exactly like the existing role/permission system already allowed — the codebase turned out to already define "administrator" structurally (a StaffProfile whose role holds MANAGE_STAFF), not as a name or a business.ownerId-style field, and last-administrator protection already existed. The real work was: closing the gap between that and a full admin-management UI, fixing two OTP-migration bugs the new spec's requirements exposed, and making the existing protection transactional.

Businesses can now have any number of Administrators, added via Invite Administrator (name + email → OTP-verified before access), promoted/demoted via the existing staff role dropdown (now intercepted with a confirmation dialog when it crosses the Administrator boundary), or removed via the existing deactivate flow.
Two real bugs found and fixed, both required for the spec's "unverified email must not get access":
Invitation acceptance (/invite/accept) never actually verified an OTP — it silently relied on a sign-in path that would have failed for every real invitee. It now sends and verifies a real code, reusing the shared OtpCodeStep UI.
A newly-invited (not-yet-accepted) account could sign in via plain email OTP before ever touching the invite link, because sign-in only checked "does a User row exist," not whether it was verified. Fixed in the shared OTP service, benefiting every invitation, not just administrators'.
Last-administrator protection (already existed) is now transactional — a SELECT ... FOR UPDATE lock on the business row closes the race where two concurrent removals of two different administrators could both succeed.
Administrators (any staff member, in fact) can now change their own email — new address must be OTP-verified before it replaces the login email.
No hidden installer privilege: confirmed via code audit and a live install → the account is a plain Administrator-role staff member, provable identically via the Team UI as any later-added one.
NextChair Manager and platform Super Administrator are untouched.
2. Prisma/database changes
No schema changes. One data-only migration: prisma/migrations/20260905214944_administrator_role_rename — UPDATE Role SET name = 'Administrator' WHERE name = 'Owner' AND isSystem = true. Runs through the normal migration-deploy process; not something to run by hand.

3. How Administrator roles are represented
A per-business Role row named "Administrator" (was "Owner"), isSystem: true, holding every Permission — created identically at install and in dev seed data. This is what "Invite"/"Make Administrator" assign.
Whether a StaffProfile currently counts as an Administrator is permission-based (holds MANAGE_STAFF), not a name check — the same definition the codebase already used for last-owner protection (isStaffAdministrator in staff.service.ts). This is deliberately reused rather than introduced, and stays correct if a business ever gets a custom role that also grants MANAGE_STAFF.
No Business.adminId-style field anywhere — confirmed none existed before this work either.
4. How administrator invitations work
Reuses the existing StaffInvitation model and createStaffProfile/createInvitation machinery verbatim — a new inviteAdministratorAction just pre-selects the Administrator role and uses a minimal Name+Email form (invite-administrator-form.tsx). Acceptance (accept-invitation.ts) now requires a real OTP code sent to the invited email before the account activates — expiry (7 days), resend, and revoke are all the pre-existing invitation mechanics (revoke = the existing deactivate action, relabeled "Revoke Invitation" while pending).

5. How last-administrator protection is enforced
Server-side only, inside staff.service.ts's updateStaffAsOwner (role change) and deactivateStaff (remove), each wrapped in prisma.$transaction that first locks the business row (SELECT ... FOR UPDATE), then counts other active Administrators before allowing the change. Verified with a genuine concurrency test (two simultaneous removals against a 2-admin business) and manually in-browser (see below).

6. Installation flow changes
Only the role name and one line of wizard copy changed ("This becomes the first Administrator..."). No structural UX change — still collects name/email, verifies via OTP, creates/activates the account, assigns the role, exactly as before.

7. Files changed
New: the migration; email-change.service.ts + change-email.ts action + change-email-section.tsx; invite-administrator-form.tsx + add-administrator/page.tsx; 3 new integration test files.

Modified — core: staff.service.ts (transactional protection, isStaffAdministrator), staff-invitation.service.ts, otp.service.ts (onlyIfUserExists fix, email-change purpose), default-permissions.ts, permissions.ts, auth.ts (JWT email-refresh, simplified finalize path).

Modified — actions/UI: owner/actions/staff.ts, staff-detail-panel.tsx, team/page.tsx, add-staff-form.tsx, accept-invitation.ts/form/page, otp-code-step.tsx, profile-form.tsx, installer files, seed.ts, plus a terminology sweep ("owner" → "administrator" copy in breaks.ts/time-off.ts/staff-emails.ts/profile page).

8. Manual testing steps
Sign in as the seeded owner → /owner/team shows an Administrator badge and an "Invite Administrator" icon next to "+". Invite someone, accept via /invite/accept?token=... printed to the terminal (code also prints there) — they land signed in with full Manage access. Promote/demote any staff member via the role dropdown on their detail page — crossing the Administrator boundary shows a confirmation dialog. With only one Administrator left, attempting to demote or deactivate them shows "You can't remove the only administrator. Add another administrator first." Change your own email from Profile settings — the new address must be OTP-verified before it replaces the old one.

9. Deployment steps on the VPS
None beyond the normal deploy process — the migration is a standard Prisma migration that prisma migrate deploy (whatever already runs it there) will pick up automatically. No new environment variables. No changes to NextChair Manager or its authentication.

Verification performed: tsc/lint/unit — all clean; full test:integration — 219 passed (up from 207, all new); full test:e2e — same 36/37 as before this work (the 1 failure is a pre-existing, auth-unrelated flake unrelated to any of this); a full live manual pass covering every scenario above, including the last-administrator block and the email-change JWT-refresh behavior.
2026-09-05 23:32:54 +01:00

1035 lines
39 KiB
TypeScript

/**
* Dev/demo fixtures. Run with `npm run prisma:seed` (requires a reachable
* DATABASE_HOST/NAME/USER/PASSWORD — 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 { ADMINISTRATOR_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());
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: "Administrator" } },
update: {},
create: {
businessId: business.id,
name: "Administrator",
isSystem: true,
permissions: { create: ADMINISTRATOR_PERMISSIONS.map((permission) => ({ permission })) },
},
});
await syncRolePermissions(prisma, ownerRole.id, ADMINISTRATOR_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 today — Current Appointment tier 2 (owner-journeys.spec.ts
// journey 10 also depends on this being CHECKED_IN + "today", see below).
// Fixed at 11:00, not minutesFromNow — selectCurrentAppointment's tier-2
// branch (appointment.service.ts) picks a CHECKED_IN row by status alone,
// never by clock time, so there was never a need to anchor this to "right
// now". Anchoring it that way was actively harmful: whenever the seed ran
// outside Leo's own 10:00-19:00 working hours (e.g. early morning), this
// appointment landed outside those hours too, which made
// findWorkingHoursConflicts (staff.service.ts) correctly-but-unhelpfully
// flag a conflict for *any* working-hours save Leo made that day —
// including ones that never touched this appointment's own weekday. A
// fixed time inside his working hours removes that time-of-day flakiness
// entirely.
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: daysFromNow(0, 11, 0),
endAt: daysFromNow(0, 11, 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();
});