Files
NextChair/prisma/schema.prisma
T
Stefan Lazic ffee9d6754 Phase 5
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.
2026-08-02 23:44:18 +01:00

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])
}