Base app architecture:
Route groups for customer, staff and owner.
* Reusable design system with around 20 UI components.
* Complete Prisma schema.
* Authentication with email/password and Google.
* Role and permission model.
* Protected routes.
* Clean separation between features, services, components and shared utilities.
828 lines
27 KiB
Plaintext
828 lines
27 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
|
|
}
|
|
|
|
enum AppointmentStatus {
|
|
PENDING_APPROVAL
|
|
CONFIRMED
|
|
ARRIVED
|
|
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.
|
|
maxAppointmentsPerDay 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[]
|
|
}
|
|
|
|
model Account {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
type String
|
|
provider String
|
|
providerAccountId String
|
|
refresh_token String? @db.Text
|
|
access_token String? @db.Text
|
|
expires_at Int?
|
|
token_type String?
|
|
scope String?
|
|
id_token String? @db.Text
|
|
session_state String?
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([provider, providerAccountId])
|
|
}
|
|
|
|
model Session {
|
|
id String @id @default(cuid())
|
|
sessionToken String @unique
|
|
userId String
|
|
expires DateTime
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
}
|
|
|
|
model VerificationToken {
|
|
identifier String
|
|
token String @unique
|
|
expires DateTime
|
|
|
|
@@unique([identifier, token])
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// People
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/// userId is optional so a guest booking can create a CustomerProfile with
|
|
/// no account at all. Deliberately NOT unique-constrained on email/phone —
|
|
/// guest duplicate-detection is an explicit open question in
|
|
/// docs/implementation-plan.md §15 (Question 4); a hard constraint here
|
|
/// would force a premature answer and break repeat guest bookings under
|
|
/// a shared household email/phone.
|
|
model CustomerProfile {
|
|
id String @id @default(cuid())
|
|
|
|
userId String? @unique
|
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
|
|
|
firstName String
|
|
lastName String
|
|
email String
|
|
phone String?
|
|
isGuest Boolean @default(true)
|
|
|
|
preferredBarberId String?
|
|
preferredBarber StaffProfile? @relation("PreferredBarber", fields: [preferredBarberId], references: [id], onDelete: SetNull)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
appointments Appointment[]
|
|
loyaltyAccounts LoyaltyAccount[]
|
|
notes CustomerNote[]
|
|
|
|
@@index([email])
|
|
@@index([phone])
|
|
@@index([preferredBarberId])
|
|
}
|
|
|
|
model StaffProfile {
|
|
id String @id @default(cuid())
|
|
|
|
userId String @unique
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
businessId String
|
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
|
|
|
roleId String
|
|
role Role @relation(fields: [roleId], references: [id])
|
|
|
|
bio String? @db.Text
|
|
status StaffStatus @default(ACTIVE)
|
|
isBookableOnline Boolean @default(true)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
services StaffService[]
|
|
workingHours WorkingHours[]
|
|
breaks Break[]
|
|
timeOff TimeOff[]
|
|
appointments Appointment[]
|
|
preferredByCustomers CustomerProfile[] @relation("PreferredBarber")
|
|
authoredLoyaltyTransactions LoyaltyTransaction[] @relation("LoyaltyTransactionAuthor")
|
|
authoredCustomerNotes CustomerNote[] @relation("CustomerNoteAuthor")
|
|
|
|
@@index([businessId])
|
|
}
|
|
|
|
/// businessId-scoped so future multi-business use never leaks one
|
|
/// business's custom roles into another's role picker.
|
|
model Role {
|
|
id String @id @default(cuid())
|
|
|
|
businessId String
|
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
|
|
|
name String
|
|
/// true for the seeded Barber/Owner roles; false for anything an owner
|
|
/// creates later (e.g. Receptionist) — lets the UI protect system roles
|
|
/// from deletion without a separate table.
|
|
isSystem Boolean @default(true)
|
|
|
|
createdAt DateTime @default(now())
|
|
|
|
permissions RolePermission[]
|
|
staff StaffProfile[]
|
|
|
|
@@unique([businessId, name])
|
|
}
|
|
|
|
model RolePermission {
|
|
id String @id @default(cuid())
|
|
|
|
roleId String
|
|
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
|
permission Permission
|
|
|
|
@@unique([roleId, permission])
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Catalog
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
model ServiceCategory {
|
|
id String @id @default(cuid())
|
|
|
|
businessId String
|
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
|
|
|
name String
|
|
displayOrder Int @default(0)
|
|
isActive Boolean @default(true)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
services Service[]
|
|
|
|
@@index([businessId])
|
|
}
|
|
|
|
model Service {
|
|
id String @id @default(cuid())
|
|
|
|
businessId String
|
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
|
|
|
categoryId String
|
|
category ServiceCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
|
|
|
name String
|
|
description String? @db.Text
|
|
durationMinutes Int
|
|
/// Standard SQL DECIMAL — supported natively on both Postgres and MySQL.
|
|
price Decimal @db.Decimal(10, 2)
|
|
bufferBeforeMinutes Int @default(0)
|
|
bufferAfterMinutes Int @default(0)
|
|
isActive Boolean @default(true)
|
|
allowOnlineBooking Boolean @default(true)
|
|
displayOrder Int @default(0)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
staff StaffService[]
|
|
appointmentServices AppointmentService[]
|
|
loyaltyEligibleFor LoyaltyEligibleService[]
|
|
|
|
@@index([businessId])
|
|
@@index([categoryId])
|
|
}
|
|
|
|
/// Which staff can perform which service — the brief explicitly requires
|
|
/// barber-specific service availability. An optional price/duration
|
|
/// override supports a senior barber charging more for the same service.
|
|
model StaffService {
|
|
id String @id @default(cuid())
|
|
|
|
staffId String
|
|
staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade)
|
|
|
|
serviceId String
|
|
service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade)
|
|
|
|
priceOverride Decimal? @db.Decimal(10, 2)
|
|
durationOverrideMinutes Int?
|
|
|
|
@@unique([staffId, serviceId])
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Scheduling (availability inputs — see docs/implementation-plan.md §14)
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
model WorkingHours {
|
|
id String @id @default(cuid())
|
|
|
|
staffId String
|
|
staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade)
|
|
|
|
dayOfWeek Weekday
|
|
isOff Boolean @default(false)
|
|
startMinutes Int?
|
|
endMinutes Int?
|
|
|
|
@@unique([staffId, dayOfWeek])
|
|
}
|
|
|
|
/// A recurring break (e.g. daily lunch) can apply to several days, so the
|
|
/// day list is a join table (BreakDay) rather than a scalar array column —
|
|
/// MySQL has no native array type, and this schema is meant to survive a
|
|
/// provider swap without a redesign.
|
|
model Break {
|
|
id String @id @default(cuid())
|
|
|
|
staffId String
|
|
staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade)
|
|
|
|
label String
|
|
startMinutes Int
|
|
endMinutes Int
|
|
|
|
createdAt DateTime @default(now())
|
|
|
|
days BreakDay[]
|
|
}
|
|
|
|
model BreakDay {
|
|
id String @id @default(cuid())
|
|
|
|
breakId String
|
|
break Break @relation(fields: [breakId], references: [id], onDelete: Cascade)
|
|
dayOfWeek Weekday
|
|
|
|
@@unique([breakId, dayOfWeek])
|
|
}
|
|
|
|
model TimeOff {
|
|
id String @id @default(cuid())
|
|
|
|
staffId String
|
|
staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade)
|
|
|
|
startDate DateTime @db.Date
|
|
endDate DateTime @db.Date
|
|
reason String?
|
|
status TimeOffStatus @default(APPROVED)
|
|
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([staffId, startDate, endDate])
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Booking
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
model Appointment {
|
|
id String @id @default(cuid())
|
|
|
|
businessId String
|
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
|
|
|
customerId String
|
|
customer CustomerProfile @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
|
|
|
staffId String
|
|
staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Restrict)
|
|
|
|
status AppointmentStatus @default(CONFIRMED)
|
|
|
|
/// Stored as UTC instants; the business's `timezone` (see Business model)
|
|
/// is applied at the presentation/query layer, so DST transitions are a
|
|
/// rendering concern, not a storage one.
|
|
startAt DateTime
|
|
endAt DateTime
|
|
|
|
notes String? @db.Text
|
|
cancellationReason String?
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
services AppointmentService[]
|
|
loyaltyTransactions LoyaltyTransaction[]
|
|
notifications Notification[]
|
|
|
|
@@index([staffId, startAt])
|
|
@@index([businessId, startAt])
|
|
@@index([customerId])
|
|
}
|
|
|
|
/// One appointment can carry multiple services (the booking flow's "Add
|
|
/// another service" step). price/duration are snapshotted at booking time
|
|
/// so a later Service price change never rewrites booking history.
|
|
model AppointmentService {
|
|
id String @id @default(cuid())
|
|
|
|
appointmentId String
|
|
appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
|
|
|
|
serviceId String
|
|
service Service @relation(fields: [serviceId], references: [id], onDelete: Restrict)
|
|
|
|
priceAtBooking Decimal @db.Decimal(10, 2)
|
|
durationAtBookingMinutes Int
|
|
|
|
@@index([appointmentId])
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Loyalty — ledger-based by design (brief: "never simply store a stamp
|
|
// count"; every stamp add/remove/redemption is its own row).
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
model LoyaltyProgramme {
|
|
id String @id @default(cuid())
|
|
|
|
businessId String @unique
|
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
|
|
|
isEnabled Boolean @default(false)
|
|
stampsRequired Int @default(10)
|
|
|
|
rewardType RewardType @default(PERCENTAGE)
|
|
/// Percentage points or a fixed-amount value depending on rewardType;
|
|
/// null when rewardType is FREE_SERVICE.
|
|
rewardValue Decimal? @db.Decimal(10, 2)
|
|
rewardLabel String?
|
|
maxRewardValue Decimal? @db.Decimal(10, 2)
|
|
|
|
autoApplyReward Boolean @default(true)
|
|
requiresStaffApproval Boolean @default(false)
|
|
|
|
stampsExpireAfterDays Int?
|
|
rewardExpiresAfterDays Int?
|
|
|
|
earnOnCancelled Boolean @default(false)
|
|
earnOnNoShow Boolean @default(false)
|
|
|
|
cardBackgroundColor String?
|
|
cardBackgroundImageUrl String?
|
|
stampIcon String?
|
|
termsLine String?
|
|
|
|
updatedAt DateTime @updatedAt
|
|
|
|
/// An empty eligibleServices/eligibleStaff list means "all services /
|
|
/// all staff are eligible" — an explicit row is only needed to *restrict*
|
|
/// eligibility, so the common "loyalty applies to everything" case
|
|
/// requires zero extra rows.
|
|
eligibleServices LoyaltyEligibleService[]
|
|
eligibleStaff LoyaltyEligibleStaff[]
|
|
accounts LoyaltyAccount[]
|
|
}
|
|
|
|
model LoyaltyEligibleService {
|
|
id String @id @default(cuid())
|
|
loyaltyProgrammeId String
|
|
loyaltyProgramme LoyaltyProgramme @relation(fields: [loyaltyProgrammeId], references: [id], onDelete: Cascade)
|
|
serviceId String
|
|
service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([loyaltyProgrammeId, serviceId])
|
|
}
|
|
|
|
model LoyaltyEligibleStaff {
|
|
id String @id @default(cuid())
|
|
loyaltyProgrammeId String
|
|
loyaltyProgramme LoyaltyProgramme @relation(fields: [loyaltyProgrammeId], references: [id], onDelete: Cascade)
|
|
staffId String
|
|
|
|
@@unique([loyaltyProgrammeId, staffId])
|
|
}
|
|
|
|
model LoyaltyAccount {
|
|
id String @id @default(cuid())
|
|
|
|
customerId String
|
|
customer CustomerProfile @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
|
|
|
loyaltyProgrammeId String
|
|
loyaltyProgramme LoyaltyProgramme @relation(fields: [loyaltyProgrammeId], references: [id], onDelete: Cascade)
|
|
|
|
/// Cached running total for fast reads. Always derived from — and
|
|
/// reconciled against — `transactions`; the ledger, not this column, is
|
|
/// the source of truth (brief requirement).
|
|
currentStamps Int @default(0)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
transactions LoyaltyTransaction[]
|
|
rewards LoyaltyReward[]
|
|
|
|
@@unique([customerId, loyaltyProgrammeId])
|
|
}
|
|
|
|
model LoyaltyTransaction {
|
|
id String @id @default(cuid())
|
|
|
|
loyaltyAccountId String
|
|
loyaltyAccount LoyaltyAccount @relation(fields: [loyaltyAccountId], references: [id], onDelete: Cascade)
|
|
|
|
type LoyaltyTransactionType
|
|
/// +1 for a stamp, -1 for a staff correction, etc.
|
|
amount Int
|
|
reason String?
|
|
|
|
appointmentId String?
|
|
appointment Appointment? @relation(fields: [appointmentId], references: [id], onDelete: SetNull)
|
|
|
|
authoredByStaffId String?
|
|
authoredByStaff StaffProfile? @relation("LoyaltyTransactionAuthor", fields: [authoredByStaffId], references: [id], onDelete: SetNull)
|
|
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([loyaltyAccountId])
|
|
}
|
|
|
|
/// A completed/redeemed reward is recorded here *before* the account's
|
|
/// active stamp count resets, per the brief's explicit requirement.
|
|
model LoyaltyReward {
|
|
id String @id @default(cuid())
|
|
|
|
loyaltyAccountId String
|
|
loyaltyAccount LoyaltyAccount @relation(fields: [loyaltyAccountId], references: [id], onDelete: Cascade)
|
|
|
|
rewardType RewardType
|
|
value Decimal? @db.Decimal(10, 2)
|
|
label String
|
|
status RewardStatus @default(AVAILABLE)
|
|
|
|
appliedToAppointmentId String?
|
|
redeemedAt DateTime?
|
|
expiresAt DateTime?
|
|
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([loyaltyAccountId])
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Notifications — abstracted behind category + channel so an SMS/push
|
|
// provider is a new NotificationChannel value and a sender implementation,
|
|
// never a schema change.
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
model Notification {
|
|
id String @id @default(cuid())
|
|
|
|
userId String?
|
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
|
|
|
appointmentId String?
|
|
appointment Appointment? @relation(fields: [appointmentId], references: [id], onDelete: SetNull)
|
|
|
|
category NotificationCategory
|
|
channel NotificationChannel
|
|
status NotificationStatus @default(PENDING)
|
|
|
|
subject String?
|
|
body String @db.Text
|
|
|
|
sentAt DateTime?
|
|
failureReason String?
|
|
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([userId])
|
|
@@index([appointmentId])
|
|
}
|
|
|
|
model NotificationPreference {
|
|
id String @id @default(cuid())
|
|
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
category NotificationCategory
|
|
channel NotificationChannel
|
|
isEnabled Boolean @default(true)
|
|
|
|
@@unique([userId, category, channel])
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Misc
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
model CustomerNote {
|
|
id String @id @default(cuid())
|
|
|
|
customerId String
|
|
customer CustomerProfile @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
|
|
|
authorStaffId String
|
|
author StaffProfile @relation("CustomerNoteAuthor", fields: [authorStaffId], references: [id], onDelete: Restrict)
|
|
|
|
body String @db.Text
|
|
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([customerId])
|
|
}
|
|
|
|
/// Generic audit trail for staff/owner actions on sensitive records
|
|
/// (permission changes, business setting edits). Not written to by
|
|
/// anything in Phase 2 — the table exists now so Phase 4/6 features don't
|
|
/// need a migration to start using it.
|
|
model AuditLog {
|
|
id String @id @default(cuid())
|
|
|
|
actorUserId String?
|
|
action String
|
|
entityType String
|
|
entityId String
|
|
/// `Json` maps to native `jsonb`/`json` on both Postgres and MySQL.
|
|
metadata Json?
|
|
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([entityType, entityId])
|
|
@@index([actorUserId])
|
|
}
|