1159 lines
43 KiB
Plaintext
1159 lines
43 KiB
Plaintext
// NextChair — Phase 2 database schema.
|
|
//
|
|
// Portability: every model deliberately avoids Postgres-only features
|
|
// (native arrays, JSONB-specific operators, LISTEN/NOTIFY) — this paid off
|
|
// in Phase 7, when the schema moved from PostgreSQL to MySQL with only a
|
|
// provider-string change (see docs/phase-7-summary.md §"Database migration"
|
|
// for the full record: schema review, index/collation decisions, and the
|
|
// serialization-error classifier update). 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
|
|
//
|
|
// Database: MySQL 8.0 (utf8mb4 / utf8mb4_0900_ai_ci) — see
|
|
// src/lib/db/prisma.ts for the driver adapter and docs/phase-7-summary.md
|
|
// for why (the production host only supports MySQL; the exact hosted
|
|
// version was not available in this repo, so 8.0 is the documented
|
|
// development/CI target pending confirmation).
|
|
|
|
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 = "mysql"
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// 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
|
|
/// Distinct from VIEW_ALL_STAFF_APPOINTMENTS — reassign/reschedule/cancel
|
|
/// any staff member's appointment, not just look at it. See
|
|
/// docs/phase-6-summary.md, "Permission and business-isolation rules".
|
|
MANAGE_ALL_APPOINTMENTS
|
|
MANAGE_OWN_APPOINTMENTS
|
|
CHECK_IN_CUSTOMERS
|
|
MANAGE_LOYALTY_STAMPS
|
|
MANAGE_OWN_AVAILABILITY
|
|
VIEW_CUSTOMER_DIRECTORY
|
|
BOOK_ON_BEHALF_OF_CUSTOMER
|
|
/// Gates the owner dashboard specifically — kept separate from
|
|
/// MANAGE_STAFF/MANAGE_BUSINESS_PROFILE so "can see aggregate business
|
|
/// health" is its own grantable capability.
|
|
VIEW_REPORTING
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
/// One value per real, distinct trigger this app actually has (see
|
|
/// NotificationService and docs/phase-7-summary.md, "Notification types
|
|
/// implemented"). Deliberately does NOT include a type for every event the
|
|
/// Phase 7 brief brainstormed — several suggested owner/barber "conflict"
|
|
/// or "config warning" notifications are already served live by the
|
|
/// existing owner dashboard and the working-hours/breaks conflict-review
|
|
/// UI (see StaffService), which have no distinct async trigger separate
|
|
/// from rendering that page; duplicating them as stored Notification rows
|
|
/// would have no real "when do I fire this" moment. MARKETING/promotional
|
|
/// types are also omitted — marketing campaigns are explicitly out of
|
|
/// scope for this app.
|
|
enum NotificationType {
|
|
// Customer-facing
|
|
BOOKING_CONFIRMED
|
|
BOOKING_PENDING_APPROVAL
|
|
BOOKING_APPROVED
|
|
BOOKING_REMINDER
|
|
BOOKING_RESCHEDULED
|
|
/// Carries the late-cancellation flag in its rendered content rather than
|
|
/// being a separate type — same event, one boolean modifier, not two
|
|
/// distinct triggers.
|
|
BOOKING_CANCELLED
|
|
BOOKING_BARBER_CHANGED
|
|
BOOKING_COMPLETED
|
|
LOYALTY_STAMP_ADDED
|
|
LOYALTY_REWARD_EARNED
|
|
LOYALTY_REWARD_REDEEMED
|
|
LOYALTY_REWARD_EXPIRING
|
|
|
|
// Barber-facing
|
|
STAFF_NEW_APPOINTMENT
|
|
STAFF_APPOINTMENT_RESCHEDULED
|
|
STAFF_APPOINTMENT_CANCELLED
|
|
STAFF_APPOINTMENT_CHECKED_IN
|
|
STAFF_APPOINTMENT_REMINDER
|
|
STAFF_SCHEDULE_CHANGED
|
|
/// The invite itself, sent to the person being invited — see
|
|
/// StaffInvitationService. Distinct from OWNER_STAFF_INVITATION_ACCEPTED
|
|
/// below, which notifies the OWNER once they accept.
|
|
STAFF_INVITATION_RECEIVED
|
|
|
|
// Owner-facing
|
|
OWNER_NEW_BOOKING
|
|
OWNER_BOOKING_CANCELLED
|
|
OWNER_NO_SHOW
|
|
OWNER_STAFF_INVITATION_ACCEPTED
|
|
/// Self-referential — the notification background processor raises this
|
|
/// for the owner when it observes repeated delivery failures, rather
|
|
/// than a domain workflow triggering it directly. See reminder-worker.ts.
|
|
OWNER_REPEATED_DELIVERY_FAILURE
|
|
}
|
|
|
|
enum NotificationChannel {
|
|
EMAIL
|
|
PUSH
|
|
/// Visible in the in-app notification centre — see src/features/notifications.
|
|
IN_APP
|
|
}
|
|
|
|
/// Deliberately stops at SENT, not DELIVERED — a provider accepting an
|
|
/// email/push send is not delivery confirmation, and this app has no
|
|
/// Brevo webhook wired up to ever justify a DELIVERED state (see
|
|
/// docs/phase-7-summary.md, "Brevo elements that couldn't be verified").
|
|
enum NotificationDeliveryStatus {
|
|
PENDING
|
|
SCHEDULED
|
|
PROCESSING
|
|
SENT
|
|
FAILED
|
|
CANCELLED
|
|
SKIPPED
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// 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?
|
|
staffInvitations StaffInvitation[]
|
|
notifications Notification[]
|
|
pushSubscriptions PushSubscription[]
|
|
}
|
|
|
|
/// 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?
|
|
receivedNotifications Notification[] @relation("NotificationRecipientUser")
|
|
notificationPreferences NotificationPreference[]
|
|
pushSubscriptions PushSubscription[]
|
|
/// 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")
|
|
/// Owner-side: invitations this user has sent (see StaffInvitation).
|
|
sentStaffInvitations StaffInvitation[] @relation("StaffInvitationInvitedBy")
|
|
}
|
|
|
|
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[]
|
|
receivedNotifications Notification[] @relation("NotificationRecipientCustomer")
|
|
pushSubscriptions PushSubscription[]
|
|
|
|
@@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")
|
|
redeemedLoyaltyRewards LoyaltyReward[] @relation("LoyaltyRewardRedeemedBy")
|
|
/// Set when this profile was created via the owner "Add Staff" flow
|
|
/// rather than seeded directly — see StaffInvitationService.
|
|
invitation StaffInvitation?
|
|
|
|
@@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])
|
|
}
|
|
|
|
/// Created immediately alongside the StaffProfile/User when an owner adds a
|
|
/// staff member (see StaffInvitationService) — the profile is usable right
|
|
/// away (it already has a role, can appear on the calendar, etc.); this row
|
|
/// only tracks the "set your own password" step, so the UI can show
|
|
/// Pending vs Accepted without gating functionality on it. No real email
|
|
/// delivery this phase — `token` is surfaced as a copy-link for the owner
|
|
/// to send manually, same documented limitation as Phase 5's loyalty
|
|
/// redemption note (see docs/phase-6-summary.md).
|
|
model StaffInvitation {
|
|
id String @id @default(cuid())
|
|
|
|
businessId String
|
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
|
|
|
staffId String @unique
|
|
staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade)
|
|
|
|
email String
|
|
token String @unique
|
|
|
|
invitedByUserId String?
|
|
invitedByUser User? @relation("StaffInvitationInvitedBy", fields: [invitedByUserId], references: [id], onDelete: SetNull)
|
|
|
|
expiresAt DateTime
|
|
acceptedAt DateTime?
|
|
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([businessId])
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// 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[]
|
|
loyaltyProgrammesUsingAsFreeReward LoyaltyProgramme[]
|
|
|
|
@@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[]
|
|
|
|
@@index([staffId])
|
|
}
|
|
|
|
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
|
|
|
|
/// Snapshot of the amount deducted by a redeemed loyalty reward, if any —
|
|
/// see LoyaltyService.redeemReward. Null = no reward applied. The
|
|
/// original AppointmentService.priceAtBooking rows are never rewritten;
|
|
/// the payable total is subtotal (sum of those) minus this value,
|
|
/// computed at read time rather than also stored, so there is exactly
|
|
/// one place a future price/discount bug could hide, not two.
|
|
discountAmount Decimal? @db.Decimal(10, 2)
|
|
|
|
/// 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[]
|
|
/// Almost always zero or one row — a reward is applied to at most one
|
|
/// appointment (see LoyaltyReward.appliedToAppointmentId) — but modelled
|
|
/// as the array Prisma requires for a one-to-many back-relation.
|
|
appliedLoyaltyRewards LoyaltyReward[]
|
|
guestAccessToken GuestAppointmentAccessToken?
|
|
|
|
@@index([staffId, startAt])
|
|
@@index([businessId, startAt])
|
|
@@index([customerId])
|
|
}
|
|
|
|
/// A guest (no linked User — see CustomerProfile.userId) proves they own an
|
|
/// appointment by presenting the raw token embedded in their booking
|
|
/// confirmation link, not by knowing the appointment's id alone (a cuid is
|
|
/// unguessable but was never actually a secret — Phase 4 documented this as
|
|
/// a deliberate, temporary gap; closed here — see
|
|
/// src/services/booking/guest-access.service.ts and docs/phase-7-summary.md).
|
|
/// Only the SHA-256 hash is ever persisted; the raw token exists just long
|
|
/// enough to embed in a link (confirmation page, and later, email) and is
|
|
/// never logged. One row per appointment — (re-)issuing overwrites the
|
|
/// previous hash, so an old copy of the raw token stops working.
|
|
model GuestAppointmentAccessToken {
|
|
id String @id @default(cuid())
|
|
|
|
appointmentId String @unique
|
|
appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
|
|
|
|
tokenHash String @unique
|
|
|
|
expiresAt DateTime
|
|
revokedAt DateTime?
|
|
lastUsedAt DateTime?
|
|
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([expiresAt])
|
|
}
|
|
|
|
/// 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)
|
|
|
|
/// Which service is free when rewardType is FREE_SERVICE — null for the
|
|
/// other two reward types. See LoyaltyService.validateProgrammeConfig for
|
|
/// the "must reference an active service" rule.
|
|
freeServiceId String?
|
|
freeService Service? @relation(fields: [freeServiceId], references: [id], onDelete: SetNull)
|
|
|
|
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?
|
|
appliedToAppointment Appointment? @relation(fields: [appliedToAppointmentId], references: [id], onDelete: SetNull)
|
|
|
|
redeemedAt DateTime?
|
|
redeemedByStaffId String?
|
|
redeemedByStaff StaffProfile? @relation("LoyaltyRewardRedeemedBy", fields: [redeemedByStaffId], references: [id], onDelete: SetNull)
|
|
|
|
expiresAt DateTime?
|
|
|
|
createdAt DateTime @default(now())
|
|
|
|
notifications Notification[]
|
|
|
|
@@index([loyaltyAccountId])
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Notifications (Phase 7) — a Notification is one message to one
|
|
// recipient (the "what happened, to whom"); a NotificationDelivery is one
|
|
// attempt to get that message to them over one channel (email/push/
|
|
// in-app). Split for the same reason AppointmentService snapshots price
|
|
// separately from Service — a single message commonly needs more than one
|
|
// delivery attempt/channel with independent status, without duplicating
|
|
// the rendered content. See src/services/notifications/notification.service.ts.
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
model Notification {
|
|
id String @id @default(cuid())
|
|
|
|
businessId String
|
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
|
|
|
/// Set for staff/owner/registered-customer recipients — resolves
|
|
/// NotificationPreference and powers the in-app notification centre.
|
|
/// Null for a guest customer (no User to attach to).
|
|
recipientUserId String?
|
|
recipientUser User? @relation("NotificationRecipientUser", fields: [recipientUserId], references: [id], onDelete: Cascade)
|
|
|
|
/// Set whenever the recipient IS a customer (guest or registered) —
|
|
/// lets a guest-directed notification render/deep-link correctly even
|
|
/// with no recipientUserId.
|
|
recipientCustomerId String?
|
|
recipientCustomer CustomerProfile? @relation("NotificationRecipientCustomer", fields: [recipientCustomerId], references: [id], onDelete: Cascade)
|
|
|
|
type NotificationType
|
|
|
|
appointmentId String?
|
|
appointment Appointment? @relation(fields: [appointmentId], references: [id], onDelete: SetNull)
|
|
|
|
loyaltyRewardId String?
|
|
loyaltyReward LoyaltyReward? @relation(fields: [loyaltyRewardId], references: [id], onDelete: SetNull)
|
|
|
|
subject String?
|
|
/// Canonical rendered plain-text content — the in-app notification body
|
|
/// and the email plain-text fallback both read from here; the HTML email
|
|
/// template (see src/services/notifications/templates) renders its own
|
|
/// markup from the same underlying data, not from this string.
|
|
bodyText String @db.Text
|
|
/// Relative path (e.g. "/appointments/xyz?token=..." for a guest) —
|
|
/// never an absolute URL, so it works whether the notification is opened
|
|
/// from an email (base URL prepended at send time), the installed PWA,
|
|
/// or the in-app centre.
|
|
deepLinkPath String?
|
|
|
|
/// Prevents duplicate creation from a retried/duplicate domain call —
|
|
/// e.g. "booking-confirmed:{appointmentId}" or
|
|
/// "reminder-24h:{appointmentId}". A second attempt to create the same
|
|
/// logical notification is a no-op (see NotificationService.notify).
|
|
idempotencyKey String @unique
|
|
|
|
/// Only meaningful for a recipientUserId notification with an IN_APP
|
|
/// delivery — the in-app centre's read/unread state.
|
|
readAt DateTime?
|
|
|
|
createdAt DateTime @default(now())
|
|
|
|
deliveries NotificationDelivery[]
|
|
|
|
@@index([businessId, createdAt])
|
|
@@index([recipientUserId, readAt])
|
|
@@index([recipientCustomerId])
|
|
@@index([appointmentId])
|
|
}
|
|
|
|
model NotificationDelivery {
|
|
id String @id @default(cuid())
|
|
|
|
notificationId String
|
|
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
|
|
|
|
channel NotificationChannel
|
|
status NotificationDeliveryStatus @default(PENDING)
|
|
|
|
/// Null = process as soon as the background worker picks it up; set for
|
|
/// a scheduled appointment reminder.
|
|
scheduledFor DateTime?
|
|
|
|
attemptCount Int @default(0)
|
|
lastAttemptedAt DateTime?
|
|
|
|
providerName String?
|
|
providerMessageId String?
|
|
/// A short, safe-to-display summary only — never the provider's raw
|
|
/// error payload, which can include request details worth not logging.
|
|
failureReason String?
|
|
|
|
sentAt DateTime?
|
|
|
|
createdAt DateTime @default(now())
|
|
|
|
@@unique([notificationId, channel])
|
|
@@index([status, scheduledFor])
|
|
}
|
|
|
|
/// Only the few NotificationTypes that are genuinely optional (currently:
|
|
/// the two reminder types) ever consult this — required transactional
|
|
/// types always send regardless of any row here, by design (see
|
|
/// NotificationService, PREFERENCE_CONTROLLED_TYPES). No stored
|
|
/// "category" column: which types are preference-controlled is an
|
|
/// application-layer policy, not a schema fact, so it can change without
|
|
/// a migration.
|
|
model NotificationPreference {
|
|
id String @id @default(cuid())
|
|
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
type NotificationType
|
|
channel NotificationChannel
|
|
isEnabled Boolean @default(true)
|
|
|
|
@@unique([userId, type, channel])
|
|
}
|
|
|
|
/// One browser/device's Web Push subscription — a user can have several
|
|
/// (phone + laptop). Scoped to a business (not just a user) because a
|
|
/// guest customer, who has no User at all, can still subscribe from
|
|
/// whatever device they're on right after booking; recipientUserId is
|
|
/// null for that case, matching the same "user OR customer" recipient
|
|
/// shape as Notification itself.
|
|
model PushSubscription {
|
|
id String @id @default(cuid())
|
|
|
|
businessId String
|
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
|
|
|
userId String?
|
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
customerId String?
|
|
customer CustomerProfile? @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
|
|
|
/// The push service's own subscription URL — long (some real-world push
|
|
/// services exceed 191 chars), hence the explicit length instead of
|
|
/// Prisma's default VARCHAR(191) for a unique-constrained String.
|
|
endpoint String @unique @db.VarChar(500)
|
|
/// The subscription's own public key/auth secret (from
|
|
/// PushSubscription.toJSON().keys in the browser) — needed to encrypt
|
|
/// each push payload; not a credential of ours to protect the way
|
|
/// EMAIL_SMTP_PASSWORD is, but still never logged (see reminder-worker.ts).
|
|
p256dhKey String
|
|
authKey String
|
|
|
|
/// User-facing label so Settings can show "iPhone — Safari" instead of a
|
|
/// raw endpoint — best-effort, derived from the User-Agent at subscribe
|
|
/// time, not re-verified later.
|
|
deviceLabel String?
|
|
|
|
createdAt DateTime @default(now())
|
|
lastUsedAt DateTime?
|
|
/// Set when the push service itself reports the subscription is gone
|
|
/// (410/404 — see reminder-worker.ts) or the user explicitly
|
|
/// unsubscribes; never deleted outright, so "why did this stop
|
|
/// receiving pushes" stays answerable.
|
|
revokedAt DateTime?
|
|
|
|
@@index([userId])
|
|
@@index([customerId])
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// 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])
|
|
}
|