Phase 2
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.
This commit is contained in:
201 files changed
+31984
No files matched your search
@@ -0,0 +1,827 @@
|
||||
// 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])
|
||||
}
|
||||
Reference in new issue
Block a user