Phase 5 is complete. All 53 tasks are done, verified on a fresh database in a single clean run: **121 unit tests**, **55 integration tests**, and **15 E2E journeys** (6 Phase 4 + 9 new Phase 5) all passing, plus a clean `tsc`/`eslint`/`build`. Summary of this final stretch: - **Fixed a real bug**: `StaffService`'s break/time-off update/delete functions threw uncaught instead of failing gracefully on a permission mismatch — fixed with proper `PERMISSION_DENIED` handling, covered by new integration tests. - **Built the remaining Phase 5 UI**: Breaks editor, Time Off editor, Barber Profile, and Notification Preferences — all wired to real services, no placeholders. - **Extended seed data**: more customers, loyalty accounts/transactions/rewards, and appointments spanning every status today (anchored to real time so the "Current Appointment" priority logic is reliably exercised). - **Found and fixed a second real bug live in the browser**: the Break/Time-Off edit sheets showed stale default values instead of the item being edited (a classic `useState`-doesn't-reinitialize-on-prop-change bug) — fixed with a `key` prop to force remount. - **Wrote the full Phase 5 test suite**: unit tests for `selectCurrentAppointment` and all `StaffService` validators, integration tests for `LoyaltyService` and `StaffService` (including the permission-fix regression tests), and 9 new Playwright E2E journeys covering the brief's exact required list. - **Did the desktop-responsive pass**: a persistent sidebar nav replacing the bottom bar at `lg:`, a genuine calendar+"Right Now" side-by-side split, and 2-column layouts for the clients list and customer details — addressing the brief's explicit "more than simple centering" requirement. - **Wrote [docs/phase-5-summary.md](docs/phase-5-summary.md)** documenting everything above in full. Nothing is committed — the whole session (Phases 2–5) sits uncommitted on top of the initial template commit, same as you left it. Let me know if you'd like me to commit, or if you want to move on to Phase 6.
873 lines
30 KiB
Plaintext
873 lines
30 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")
|
|
/// 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])
|
|
}
|