// NextChair — Phase 2 database schema. // // Portability: every model deliberately avoids Postgres-only features // (native arrays, JSONB-specific operators, LISTEN/NOTIFY) so the // `datasource.provider` below can later change to "mysql" with a // `prisma migrate diff` instead of a schema rewrite. Concretely: // - no `String[]` / scalar-list columns anywhere (see BreakDay join table) // - `Decimal`, `Json`, `@db.Date`, `@db.Text` are used because Prisma maps // all four to native, equivalent types on both Postgres and MySQL // - recurring wall-clock times (working hours, breaks) are stored as // `Int` minutes-from-midnight rather than a DB TIME type, so scheduling // math lives in application code, not in provider-specific SQL generator client { provider = "prisma-client" output = "../generated/prisma" } // Connection URL lives in prisma.config.ts (Prisma 7) and is passed to // PrismaClient at runtime via a driver adapter — see src/lib/db/prisma.ts. datasource db { provider = "postgresql" } // ───────────────────────────────────────────────────────────────────────── // Enums // ───────────────────────────────────────────────────────────────────────── enum Weekday { MONDAY TUESDAY WEDNESDAY THURSDAY FRIDAY SATURDAY SUNDAY } /// Code-defined permission set, granted to a Role via RolePermission. /// Kept as an enum (not a user-editable table) — the set of possible /// actions is defined by the application, not by end users, in v1. enum Permission { MANAGE_BUSINESS_PROFILE MANAGE_BOOKING_RULES MANAGE_SERVICES MANAGE_STAFF MANAGE_LOYALTY_PROGRAMME MANAGE_NOTIFICATION_SETTINGS VIEW_ALL_STAFF_APPOINTMENTS MANAGE_OWN_APPOINTMENTS CHECK_IN_CUSTOMERS MANAGE_LOYALTY_STAMPS MANAGE_OWN_AVAILABILITY VIEW_CUSTOMER_DIRECTORY BOOK_ON_BEHALF_OF_CUSTOMER } enum StaffStatus { ACTIVE ON_BREAK INACTIVE } /// PENDING -> CONFIRMED -> CHECKED_IN -> IN_PROGRESS -> COMPLETED is the /// happy path. CANCELLED is reachable from PENDING/CONFIRMED/CHECKED_IN. /// NO_SHOW is reachable only from CONFIRMED (once CHECKED_IN, the customer /// did show up). COMPLETED/CANCELLED/NO_SHOW are terminal. The transition /// map itself lives in services/scheduling/appointment.service.ts, not /// here — this enum is just the vocabulary. enum AppointmentStatus { PENDING CONFIRMED CHECKED_IN IN_PROGRESS COMPLETED CANCELLED NO_SHOW } enum TimeOffStatus { PENDING APPROVED DECLINED } enum RewardType { PERCENTAGE FIXED_AMOUNT FREE_SERVICE } enum RewardStatus { AVAILABLE REDEEMED EXPIRED } enum LoyaltyTransactionType { STAMP_ADDED STAMP_REMOVED STAMP_EXPIRED REWARD_REDEEMED } enum NotificationCategory { BOOKING_CONFIRMATION BOOKING_REMINDER BOOKING_CHANGED BOOKING_CANCELLED LOYALTY_REWARD_EARNED LOYALTY_REWARD_AVAILABLE MARKETING } enum NotificationChannel { EMAIL SMS PUSH } enum NotificationStatus { PENDING SENT FAILED } // ───────────────────────────────────────────────────────────────────────── // Business // ───────────────────────────────────────────────────────────────────────── /// The tenant. v1 ships with exactly one row, seeded at setup time, but /// every other model hangs off `businessId` rather than assuming a /// singleton so multi-business support later is additive, not a rewrite. model Business { id String @id @default(cuid()) slug String @unique name String description String? @db.Text logoUrl String? phone String? email String? website String? instagramUrl String? facebookUrl String? /// IANA timezone, e.g. "Europe/London". All wall-clock scheduling math /// (working hours, breaks, booking rules) is interpreted in this zone. timezone String @default("Europe/London") /// ISO 4217 currency code, e.g. "GBP". currency String @default("GBP") locale String @default("en-GB") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt locations BusinessLocation[] hours BusinessHours[] settings BusinessSettings? bookingRule BookingRule? serviceCategories ServiceCategory[] services Service[] staff StaffProfile[] roles Role[] appointments Appointment[] loyaltyProgramme LoyaltyProgramme? } /// Split from Business (rather than address fields on Business itself) so /// a second location is an additive row later, not a schema migration. /// v1 only ever creates one row per business and treats it as primary. model BusinessLocation { id String @id @default(cuid()) businessId String business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) name String @default("Main Location") addressLine1 String addressLine2 String? city String region String? postalCode String country String @default("GB") latitude Decimal? @db.Decimal(9, 6) longitude Decimal? @db.Decimal(9, 6) isPrimary Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([businessId]) } /// Recurring weekly opening hours for the business as a whole (distinct /// from a staff member's individual WorkingHours). model BusinessHours { id String @id @default(cuid()) businessId String business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) dayOfWeek Weekday isClosed Boolean @default(false) /// Minutes since midnight, e.g. 600 = 10:00. Ignored when isClosed. opensAtMinutes Int? closesAtMinutes Int? @@unique([businessId, dayOfWeek]) } model BusinessSettings { id String @id @default(cuid()) businessId String @unique business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) cancellationPolicy String? @db.Text lateArrivalPolicy String? @db.Text bookingTerms String? @db.Text privacyPolicyUrl String? updatedAt DateTime @updatedAt } /// Scheduling policy — see docs/implementation-plan.md §6/§16. Every field /// has a sane default so the row can be created once at business setup and /// tuned later without a migration. model BookingRule { id String @id @default(cuid()) businessId String @unique business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) minNoticeMinutes Int @default(120) maxAdvanceDays Int @default(60) cancellationWindowHours Int @default(24) rescheduleWindowHours Int @default(24) slotIntervalMinutes Int @default(30) bufferBeforeMinutes Int @default(0) bufferAfterMinutes Int @default(5) /// null = no daily cap. Business-wide total across all staff. maxAppointmentsPerDay Int? /// null = no per-staff cap. Distinct from maxAppointmentsPerDay — this /// limits how many appointments any single staff member can take in a /// day, independent of the shop-wide total. maxAppointmentsPerStaffPerDay Int? customerCanChooseStaff Boolean @default(true) allowGuestBooking Boolean @default(true) requireApproval Boolean @default(false) /// Modelled now, not enforced anywhere yet — deposits are a future /// payments phase (see docs/implementation-plan.md §6, "later phase"). requireDeposit Boolean @default(false) updatedAt DateTime @updatedAt } // ───────────────────────────────────────────────────────────────────────── // Auth (Auth.js / NextAuth v5 + Prisma adapter standard shape) // ───────────────────────────────────────────────────────────────────────── /// The single identity table for every human in the system — customers, /// staff, and owners all authenticate as a User; role/permission comes /// from whichever profile (CustomerProfile / StaffProfile) hangs off it. /// A staff member who is also an owner is one User with one StaffProfile /// whose Role carries the extra permissions — never two accounts. model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? /// E.164 format. Nullable + unique now so phone-based login (brief: /// "design so phone login can easily be added later") is additive. phone String? @unique phoneVerified DateTime? image String? /// null for OAuth-only users (no password set). passwordHash String? /// Platform-level flag, separate from the business Role/Permission /// model — out of scope to exercise in Phase 2, but the brief's "Future /// Roles" note calls out a platform administrator explicitly. isSuperAdmin Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt accounts Account[] sessions Session[] customerProfile CustomerProfile? staffProfile StaffProfile? notifications Notification[] notificationPreferences NotificationPreference[] /// Whoever actually cancelled an appointment — the customer themselves, /// or the staff member who did it on their behalf. cancelledAppointments Appointment[] @relation("AppointmentCancelledBy") /// Whoever initiated a booking — see Appointment.createdByUserId. createdAppointments Appointment[] @relation("AppointmentCreatedBy") } model Account { id String @id @default(cuid()) userId String type String provider String providerAccountId String refresh_token String? @db.Text access_token String? @db.Text expires_at Int? token_type String? scope String? id_token String? @db.Text session_state String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerAccountId]) } model Session { id String @id @default(cuid()) sessionToken String @unique userId String expires DateTime user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model VerificationToken { identifier String token String @unique expires DateTime @@unique([identifier, token]) } // ───────────────────────────────────────────────────────────────────────── // People // ───────────────────────────────────────────────────────────────────────── /// userId is optional so a guest booking can create a CustomerProfile with /// no account at all. Deliberately NOT unique-constrained on email/phone — /// guest duplicate-detection is an explicit open question in /// docs/implementation-plan.md §15 (Question 4); a hard constraint here /// would force a premature answer and break repeat guest bookings under /// a shared household email/phone. model CustomerProfile { id String @id @default(cuid()) userId String? @unique user User? @relation(fields: [userId], references: [id], onDelete: SetNull) firstName String lastName String email String phone String? isGuest Boolean @default(true) preferredBarberId String? preferredBarber StaffProfile? @relation("PreferredBarber", fields: [preferredBarberId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt appointments Appointment[] loyaltyAccounts LoyaltyAccount[] notes CustomerNote[] @@index([email]) @@index([phone]) @@index([preferredBarberId]) } model StaffProfile { id String @id @default(cuid()) userId String @unique user User @relation(fields: [userId], references: [id], onDelete: Cascade) businessId String business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) roleId String role Role @relation(fields: [roleId], references: [id]) bio String? @db.Text status StaffStatus @default(ACTIVE) isBookableOnline Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt services StaffService[] workingHours WorkingHours[] breaks Break[] timeOff TimeOff[] appointments Appointment[] preferredByCustomers CustomerProfile[] @relation("PreferredBarber") authoredLoyaltyTransactions LoyaltyTransaction[] @relation("LoyaltyTransactionAuthor") authoredCustomerNotes CustomerNote[] @relation("CustomerNoteAuthor") @@index([businessId]) } /// businessId-scoped so future multi-business use never leaks one /// business's custom roles into another's role picker. model Role { id String @id @default(cuid()) businessId String business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) name String /// true for the seeded Barber/Owner roles; false for anything an owner /// creates later (e.g. Receptionist) — lets the UI protect system roles /// from deletion without a separate table. isSystem Boolean @default(true) createdAt DateTime @default(now()) permissions RolePermission[] staff StaffProfile[] @@unique([businessId, name]) } model RolePermission { id String @id @default(cuid()) roleId String role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) permission Permission @@unique([roleId, permission]) } // ───────────────────────────────────────────────────────────────────────── // Catalog // ───────────────────────────────────────────────────────────────────────── model ServiceCategory { id String @id @default(cuid()) businessId String business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) name String displayOrder Int @default(0) isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt services Service[] @@index([businessId]) } model Service { id String @id @default(cuid()) businessId String business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) categoryId String category ServiceCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade) name String description String? @db.Text durationMinutes Int /// Standard SQL DECIMAL — supported natively on both Postgres and MySQL. price Decimal @db.Decimal(10, 2) bufferBeforeMinutes Int @default(0) bufferAfterMinutes Int @default(0) isActive Boolean @default(true) allowOnlineBooking Boolean @default(true) displayOrder Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt staff StaffService[] appointmentServices AppointmentService[] loyaltyEligibleFor LoyaltyEligibleService[] @@index([businessId]) @@index([categoryId]) } /// Which staff can perform which service — the brief explicitly requires /// barber-specific service availability. An optional price/duration /// override supports a senior barber charging more for the same service. model StaffService { id String @id @default(cuid()) staffId String staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade) serviceId String service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade) priceOverride Decimal? @db.Decimal(10, 2) durationOverrideMinutes Int? @@unique([staffId, serviceId]) } // ───────────────────────────────────────────────────────────────────────── // Scheduling (availability inputs — see docs/implementation-plan.md §14) // ───────────────────────────────────────────────────────────────────────── model WorkingHours { id String @id @default(cuid()) staffId String staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade) dayOfWeek Weekday isOff Boolean @default(false) startMinutes Int? endMinutes Int? @@unique([staffId, dayOfWeek]) } /// A recurring break (e.g. daily lunch) can apply to several days, so the /// day list is a join table (BreakDay) rather than a scalar array column — /// MySQL has no native array type, and this schema is meant to survive a /// provider swap without a redesign. model Break { id String @id @default(cuid()) staffId String staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade) label String startMinutes Int endMinutes Int createdAt DateTime @default(now()) days BreakDay[] } model BreakDay { id String @id @default(cuid()) breakId String break Break @relation(fields: [breakId], references: [id], onDelete: Cascade) dayOfWeek Weekday @@unique([breakId, dayOfWeek]) } model TimeOff { id String @id @default(cuid()) staffId String staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade) startDate DateTime @db.Date endDate DateTime @db.Date reason String? status TimeOffStatus @default(APPROVED) /// Both null = blocks the whole day, every day in [startDate, endDate] /// (the original, pre-Phase-5 behaviour — every existing row has both /// null). When set, this row instead blocks only [startMinutes, /// endMinutes] on each day in range, same as a break. This is also how /// Phase 5's "Block Time" (a one-off slot like a meeting or personal /// break) is represented — a TimeOff row with startDate = endDate and a /// short reason — rather than inventing a separate blocked-time model; /// see docs/phase-5-summary.md. startMinutes Int? endMinutes Int? createdAt DateTime @default(now()) @@index([staffId, startDate, endDate]) } // ───────────────────────────────────────────────────────────────────────── // Booking // ───────────────────────────────────────────────────────────────────────── model Appointment { id String @id @default(cuid()) businessId String business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) customerId String customer CustomerProfile @relation(fields: [customerId], references: [id], onDelete: Cascade) staffId String staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Restrict) status AppointmentStatus @default(CONFIRMED) /// Stored as UTC instants; the business's `timezone` (see Business model) /// is applied at the presentation/query layer, so DST transitions are a /// rendering concern, not a storage one. startAt DateTime endAt DateTime notes String? @db.Text /// Cancellation is a status transition, never a delete — these three /// fields are only ever set together, when status becomes CANCELLED. cancellationReason String? cancelledAt DateTime? cancelledByUserId String? cancelledByUser User? @relation("AppointmentCancelledBy", fields: [cancelledByUserId], references: [id], onDelete: SetNull) /// Who initiated this booking — the customer themselves if self-service /// (or null for a guest with no account), or the staff member if created /// on the customer's behalf (Phase 5 "Book for customer"/"Follow-up"). /// Purely provenance; never used for permission checks (ownership is via /// customerId/staffId, not this field). createdByUserId String? createdByUser User? @relation("AppointmentCreatedBy", fields: [createdByUserId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt services AppointmentService[] loyaltyTransactions LoyaltyTransaction[] notifications Notification[] @@index([staffId, startAt]) @@index([businessId, startAt]) @@index([customerId]) } /// One appointment can carry multiple services (the booking flow's "Add /// another service" step). price/duration are snapshotted at booking time /// so a later Service price change never rewrites booking history. model AppointmentService { id String @id @default(cuid()) appointmentId String appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade) serviceId String service Service @relation(fields: [serviceId], references: [id], onDelete: Restrict) priceAtBooking Decimal @db.Decimal(10, 2) durationAtBookingMinutes Int @@index([appointmentId]) } // ───────────────────────────────────────────────────────────────────────── // Loyalty — ledger-based by design (brief: "never simply store a stamp // count"; every stamp add/remove/redemption is its own row). // ───────────────────────────────────────────────────────────────────────── model LoyaltyProgramme { id String @id @default(cuid()) businessId String @unique business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) isEnabled Boolean @default(false) stampsRequired Int @default(10) rewardType RewardType @default(PERCENTAGE) /// Percentage points or a fixed-amount value depending on rewardType; /// null when rewardType is FREE_SERVICE. rewardValue Decimal? @db.Decimal(10, 2) rewardLabel String? maxRewardValue Decimal? @db.Decimal(10, 2) autoApplyReward Boolean @default(true) requiresStaffApproval Boolean @default(false) stampsExpireAfterDays Int? rewardExpiresAfterDays Int? earnOnCancelled Boolean @default(false) earnOnNoShow Boolean @default(false) cardBackgroundColor String? cardBackgroundImageUrl String? stampIcon String? termsLine String? updatedAt DateTime @updatedAt /// An empty eligibleServices/eligibleStaff list means "all services / /// all staff are eligible" — an explicit row is only needed to *restrict* /// eligibility, so the common "loyalty applies to everything" case /// requires zero extra rows. eligibleServices LoyaltyEligibleService[] eligibleStaff LoyaltyEligibleStaff[] accounts LoyaltyAccount[] } model LoyaltyEligibleService { id String @id @default(cuid()) loyaltyProgrammeId String loyaltyProgramme LoyaltyProgramme @relation(fields: [loyaltyProgrammeId], references: [id], onDelete: Cascade) serviceId String service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade) @@unique([loyaltyProgrammeId, serviceId]) } model LoyaltyEligibleStaff { id String @id @default(cuid()) loyaltyProgrammeId String loyaltyProgramme LoyaltyProgramme @relation(fields: [loyaltyProgrammeId], references: [id], onDelete: Cascade) staffId String @@unique([loyaltyProgrammeId, staffId]) } model LoyaltyAccount { id String @id @default(cuid()) customerId String customer CustomerProfile @relation(fields: [customerId], references: [id], onDelete: Cascade) loyaltyProgrammeId String loyaltyProgramme LoyaltyProgramme @relation(fields: [loyaltyProgrammeId], references: [id], onDelete: Cascade) /// Cached running total for fast reads. Always derived from — and /// reconciled against — `transactions`; the ledger, not this column, is /// the source of truth (brief requirement). currentStamps Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt transactions LoyaltyTransaction[] rewards LoyaltyReward[] @@unique([customerId, loyaltyProgrammeId]) } model LoyaltyTransaction { id String @id @default(cuid()) loyaltyAccountId String loyaltyAccount LoyaltyAccount @relation(fields: [loyaltyAccountId], references: [id], onDelete: Cascade) type LoyaltyTransactionType /// +1 for a stamp, -1 for a staff correction, etc. amount Int reason String? appointmentId String? appointment Appointment? @relation(fields: [appointmentId], references: [id], onDelete: SetNull) authoredByStaffId String? authoredByStaff StaffProfile? @relation("LoyaltyTransactionAuthor", fields: [authoredByStaffId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) @@index([loyaltyAccountId]) } /// A completed/redeemed reward is recorded here *before* the account's /// active stamp count resets, per the brief's explicit requirement. model LoyaltyReward { id String @id @default(cuid()) loyaltyAccountId String loyaltyAccount LoyaltyAccount @relation(fields: [loyaltyAccountId], references: [id], onDelete: Cascade) rewardType RewardType value Decimal? @db.Decimal(10, 2) label String status RewardStatus @default(AVAILABLE) appliedToAppointmentId String? redeemedAt DateTime? expiresAt DateTime? createdAt DateTime @default(now()) @@index([loyaltyAccountId]) } // ───────────────────────────────────────────────────────────────────────── // Notifications — abstracted behind category + channel so an SMS/push // provider is a new NotificationChannel value and a sender implementation, // never a schema change. // ───────────────────────────────────────────────────────────────────────── model Notification { id String @id @default(cuid()) userId String? user User? @relation(fields: [userId], references: [id], onDelete: SetNull) appointmentId String? appointment Appointment? @relation(fields: [appointmentId], references: [id], onDelete: SetNull) category NotificationCategory channel NotificationChannel status NotificationStatus @default(PENDING) subject String? body String @db.Text sentAt DateTime? failureReason String? createdAt DateTime @default(now()) @@index([userId]) @@index([appointmentId]) } model NotificationPreference { id String @id @default(cuid()) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) category NotificationCategory channel NotificationChannel isEnabled Boolean @default(true) @@unique([userId, category, channel]) } // ───────────────────────────────────────────────────────────────────────── // Misc // ───────────────────────────────────────────────────────────────────────── model CustomerNote { id String @id @default(cuid()) customerId String customer CustomerProfile @relation(fields: [customerId], references: [id], onDelete: Cascade) authorStaffId String author StaffProfile @relation("CustomerNoteAuthor", fields: [authorStaffId], references: [id], onDelete: Restrict) body String @db.Text createdAt DateTime @default(now()) /// Set automatically whenever body changes — the only edit signal this /// phase needs (see StaffService and docs/phase-5-summary.md for the /// note permission model: a staff member may edit/delete only their own /// note). A full field-level audit trail was judged unnecessary for v1. updatedAt DateTime @updatedAt @@index([customerId]) } /// Generic audit trail for staff/owner actions on sensitive records /// (permission changes, business setting edits). Not written to by /// anything in Phase 2 — the table exists now so Phase 4/6 features don't /// need a migration to start using it. model AuditLog { id String @id @default(cuid()) actorUserId String? action String entityType String entityId String /// `Json` maps to native `jsonb`/`json` on both Postgres and MySQL. metadata Json? createdAt DateTime @default(now()) @@index([entityType, entityId]) @@index([actorUserId]) }