Files
NextChair/prisma/schema.prisma
T
Stefan Lazic 8b63cc66a9 Booking Engine
Architecture: Six focused services with the BookingService/SchedulingService overlap resolved by giving SchedulingService exactly one job, the transactional double-booking guard , while BookingService owns the create/reschedule/cancel workflow around it.

59 unit tests, 24 integration tests
2026-08-02 18:36:39 +01:00

847 lines
28 KiB
Plaintext

// 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")
}
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)
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)
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())
@@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])
}