Files
NextChair/prisma/schema.prisma
T
Stefan Lazic 769d3f9ebc PWA Settings
Fixed webmanifest generation and pwa icon.

Made nav bar text optional.
2026-08-07 13:38:11 +01:00

1572 lines
61 KiB
Plaintext

// NextChair — Phase 2 database schema.
//
// Portability: every model deliberately avoids Postgres-only features
// (native arrays, JSONB-specific operators, LISTEN/NOTIFY) — this paid off
// in Phase 7, when the schema moved from PostgreSQL to MySQL with only a
// provider-string change (see docs/phase-7-summary.md §"Database migration"
// for the full record: schema review, index/collation decisions, and the
// serialization-error classifier update). Concretely:
// - no `String[]` / scalar-list columns anywhere (see BreakDay join table)
// - `Decimal`, `Json`, `@db.Date`, `@db.Text` are used because Prisma maps
// all four to native, equivalent types on both Postgres and MySQL
// - recurring wall-clock times (working hours, breaks) are stored as
// `Int` minutes-from-midnight rather than a DB TIME type, so scheduling
// math lives in application code, not in provider-specific SQL
//
// Database: MySQL 8.0 (utf8mb4 / utf8mb4_0900_ai_ci) — see
// src/lib/db/prisma.ts for the driver adapter and docs/phase-7-summary.md
// for why (the production host only supports MySQL; the exact hosted
// version was not available in this repo, so 8.0 is the documented
// development/CI target pending confirmation).
generator client {
provider = "prisma-client"
output = "../generated/prisma"
}
// Connection URL lives in prisma.config.ts (Prisma 7) and is passed to
// PrismaClient at runtime via a driver adapter — see src/lib/db/prisma.ts.
datasource db {
provider = "mysql"
}
// ─────────────────────────────────────────────────────────────────────────
// Enums
// ─────────────────────────────────────────────────────────────────────────
enum Weekday {
MONDAY
TUESDAY
WEDNESDAY
THURSDAY
FRIDAY
SATURDAY
SUNDAY
}
/// Code-defined permission set, granted to a Role via RolePermission.
/// Kept as an enum (not a user-editable table) — the set of possible
/// actions is defined by the application, not by end users, in v1.
enum Permission {
MANAGE_BUSINESS_PROFILE
MANAGE_BOOKING_RULES
MANAGE_SERVICES
MANAGE_STAFF
MANAGE_LOYALTY_PROGRAMME
MANAGE_NOTIFICATION_SETTINGS
VIEW_ALL_STAFF_APPOINTMENTS
/// Distinct from VIEW_ALL_STAFF_APPOINTMENTS — reassign/reschedule/cancel
/// any staff member's appointment, not just look at it. See
/// docs/phase-6-summary.md, "Permission and business-isolation rules".
MANAGE_ALL_APPOINTMENTS
MANAGE_OWN_APPOINTMENTS
CHECK_IN_CUSTOMERS
MANAGE_LOYALTY_STAMPS
MANAGE_OWN_AVAILABILITY
VIEW_CUSTOMER_DIRECTORY
BOOK_ON_BEHALF_OF_CUSTOMER
/// Gates the owner dashboard specifically — kept separate from
/// MANAGE_STAFF/MANAGE_BUSINESS_PROFILE so "can see aggregate business
/// health" is its own grantable capability.
VIEW_REPORTING
/// Phase 9 — the Media Manager (upload/select/delete library assets),
/// used by every image field across the app (branding, loyalty card,
/// staff photos, gallery/portfolio). See src/services/media.
MANAGE_MEDIA_LIBRARY
/// Phase 9 — hide/unhide imported Google reviews and tune the
/// visibility filter policy. Separate from MANAGE_BUSINESS_PROFILE since
/// it's reviewing third-party content, not editing business data.
MANAGE_REVIEWS
}
/// Phase 9 — where a BusinessGalleryImage is displayed: the Home page hero
/// gallery, or the Portfolio tab. One model/service backs both rather than
/// two near-duplicate "gallery" features — see src/services/gallery.
enum GalleryPlacement {
HERO
PORTFOLIO
}
enum StaffStatus {
ACTIVE
ON_BREAK
INACTIVE
}
/// PENDING -> CONFIRMED -> CHECKED_IN -> IN_PROGRESS -> COMPLETED is the
/// happy path. CANCELLED is reachable from PENDING/CONFIRMED/CHECKED_IN.
/// NO_SHOW is reachable only from CONFIRMED (once CHECKED_IN, the customer
/// did show up). COMPLETED/CANCELLED/NO_SHOW are terminal. The transition
/// map itself lives in services/scheduling/appointment.service.ts, not
/// here — this enum is just the vocabulary.
enum AppointmentStatus {
PENDING
CONFIRMED
CHECKED_IN
IN_PROGRESS
COMPLETED
CANCELLED
NO_SHOW
}
enum TimeOffStatus {
PENDING
APPROVED
DECLINED
}
enum RewardType {
PERCENTAGE
FIXED_AMOUNT
FREE_SERVICE
}
enum RewardStatus {
AVAILABLE
REDEEMED
EXPIRED
}
enum LoyaltyTransactionType {
STAMP_ADDED
STAMP_REMOVED
STAMP_EXPIRED
REWARD_REDEEMED
}
/// One value per real, distinct trigger this app actually has (see
/// NotificationService and docs/phase-7-summary.md, "Notification types
/// implemented"). Deliberately does NOT include a type for every event the
/// Phase 7 brief brainstormed — several suggested owner/barber "conflict"
/// or "config warning" notifications are already served live by the
/// existing owner dashboard and the working-hours/breaks conflict-review
/// UI (see StaffService), which have no distinct async trigger separate
/// from rendering that page; duplicating them as stored Notification rows
/// would have no real "when do I fire this" moment. MARKETING/promotional
/// types are also omitted — marketing campaigns are explicitly out of
/// scope for this app.
enum NotificationType {
// Customer-facing
BOOKING_CONFIRMED
BOOKING_PENDING_APPROVAL
BOOKING_APPROVED
BOOKING_REMINDER
BOOKING_RESCHEDULED
/// Carries the late-cancellation flag in its rendered content rather than
/// being a separate type — same event, one boolean modifier, not two
/// distinct triggers.
BOOKING_CANCELLED
BOOKING_BARBER_CHANGED
BOOKING_COMPLETED
LOYALTY_STAMP_ADDED
LOYALTY_REWARD_EARNED
LOYALTY_REWARD_REDEEMED
LOYALTY_REWARD_EXPIRING
// Barber-facing
STAFF_NEW_APPOINTMENT
STAFF_APPOINTMENT_RESCHEDULED
STAFF_APPOINTMENT_CANCELLED
STAFF_APPOINTMENT_CHECKED_IN
STAFF_APPOINTMENT_REMINDER
STAFF_SCHEDULE_CHANGED
/// The invite itself, sent to the person being invited — see
/// StaffInvitationService. Distinct from OWNER_STAFF_INVITATION_ACCEPTED
/// below, which notifies the OWNER once they accept.
STAFF_INVITATION_RECEIVED
// Owner-facing
OWNER_NEW_BOOKING
OWNER_BOOKING_CANCELLED
OWNER_NO_SHOW
OWNER_STAFF_INVITATION_ACCEPTED
/// Self-referential — the notification background processor raises this
/// for the owner when it observes repeated delivery failures, rather
/// than a domain workflow triggering it directly. See reminder-worker.ts.
OWNER_REPEATED_DELIVERY_FAILURE
}
enum NotificationChannel {
EMAIL
PUSH
/// Visible in the in-app notification centre — see src/features/notifications.
IN_APP
}
/// Deliberately stops at SENT, not DELIVERED — a provider accepting an
/// email/push send is not delivery confirmation, and this app has no
/// Brevo webhook wired up to ever justify a DELIVERED state (see
/// docs/phase-7-summary.md, "Brevo elements that couldn't be verified").
enum NotificationDeliveryStatus {
PENDING
SCHEDULED
PROCESSING
SENT
FAILED
CANCELLED
SKIPPED
}
/// See src/lib/installation/installation-state.ts — the one place this is
/// read. INSTALLING exists purely as an in-transaction lock state (see
/// ApplicationInstallation's own header); no caller ever observes it
/// committed, since a failed install rolls the whole transaction back to
/// no-row-at-all rather than leaving INSTALLING or FAILED sitting around.
enum InstallationStatus {
NOT_INSTALLED
INSTALLING
INSTALLED
FAILED
}
enum CalendarProvider {
GOOGLE
}
/// Per-appointment sync outcome, not the connection's overall health (see
/// StaffCalendarConnection.lastSyncError for that).
enum CalendarSyncStatus {
SYNCED
PENDING
FAILED
CANCELLED
}
// ─────────────────────────────────────────────────────────────────────────
// 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
/// Short form for tight spaces (PWA home-screen label, browser tab on
/// narrow viewports) — falls back to a truncated `name` when unset, see
/// src/lib/business/branding.ts.
shortName String?
description String? @db.Text
logoUrl String?
/// Wide banner image for the Welcome page / booking pages — distinct
/// from `logoUrl` (square mark) and optional; falls back to a plain
/// color background when unset.
coverImageUrl String?
/// Falls back to the app's neutral favicon when unset — see
/// src/app/icon.png and the branding fallback rules in
/// src/lib/business/branding.ts.
faviconUrl String?
/// Apple touch icon — iOS Safari's "Add to Home Screen" icon. Falls back
/// to the same neutral app icon as the favicon when unset. Distinct from
/// the four PWA manifest icon fields below (Android/Chrome installs read
/// the manifest's `icons` array, not this field).
appIconUrl String?
/// Hex color, e.g. "#111b15" — PWA `theme_color` / manifest and
/// browser chrome. Falls back to the app's own theme color when unset.
themeColor String?
backgroundColor String?
/// PWA manifest `icons` array (Android/Chrome home-screen install) — one
/// upload per required size/purpose rather than generating them from a
/// single source image, since there's no image-processing pipeline in
/// this app. Each falls back independently to the app's own generated
/// icon when unset — see src/app/manifest.ts.
icon192Url String?
icon512Url String?
/// "Maskable" purpose — Android adaptive-icon safe zone, must have the
/// subject inset within the center ~80% since the OS may crop to a
/// circle/squircle/rounded-square.
maskableIcon192Url String?
maskableIcon512Url String?
/// iOS `apple-touch-startup-image` splash screens shown while the app
/// launches from the home screen — Safari has no manifest-driven splash
/// generation (unlike Chrome, which derives one from the manifest's
/// icon/name/background_color automatically), so this needs explicit
/// per-device images. An array of `{ media, url }` pairs — one exact
/// `(device-width/height/pixel-ratio/orientation)` media query per
/// device, matching the bundle a generator like progressier.com
/// produces — rather than the 2 generic portrait/landscape images this
/// field held before, since a real per-device bundle is now imported
/// wholesale (see importAppIconZipAction) instead of hand-picked.
splashScreens Json?
/// Icon-only vs icon+label bottom navigation (customer and staff/owner
/// alike — see components/navigation/bottom-nav.tsx). Super
/// Administrator only, same as the rest of this display-config block —
/// see updateNavigationSettingsAction.
bottomNavShowLabels Boolean @default(true)
/// Phase 9 — Welcome page background, desktop breakpoint. Falls back to
/// the existing plain background when unset. See src/app/page.tsx.
welcomeBackgroundDesktopUrl String?
/// Phase 9 — Welcome page background, mobile breakpoint. Independent of
/// the desktop image (a wide banner rarely crops well to portrait).
welcomeBackgroundMobileUrl String?
phone String?
email String?
website String?
instagramUrl String?
facebookUrl String?
/// Phase 9 — Google Places Place ID this business's reviews sync from.
/// Null = review sync disabled for this business (see
/// src/services/reviews/review-sync.service.ts). Not validated against a
/// fixed format — Google Place IDs have no stable public regex.
googlePlaceId String?
/// Google's own place-level aggregate rating, distinct from the
/// individual Review rows below (Google's API only ever returns a
/// handful of "most relevant" individual reviews per call, but does
/// return the true aggregate rating/count for the whole place) — synced
/// by the same job. Used for the customer-facing star/count summary
/// instead of averaging just the visible review sample.
googleRating Decimal? @db.Decimal(2, 1)
googleRatingCount Int?
/// IANA timezone, e.g. "Europe/London". All wall-clock scheduling math
/// (working hours, breaks, booking rules) is interpreted in this zone.
timezone String @default("Europe/London")
/// ISO 4217 currency code, e.g. "GBP".
currency String @default("GBP")
locale String @default("en-GB")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
locations BusinessLocation[]
hours BusinessHours[]
settings BusinessSettings?
bookingRule BookingRule?
serviceCategories ServiceCategory[]
services Service[]
staff StaffProfile[]
roles Role[]
appointments Appointment[]
loyaltyProgramme LoyaltyProgramme?
staffInvitations StaffInvitation[]
notifications Notification[]
pushSubscriptions PushSubscription[]
mediaAssets MediaAsset[]
galleryImages BusinessGalleryImage[]
reviews Review[]
}
/// 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?
/// Phase 9 — Google reviews visibility policy, applied on top of the
/// per-review Review.isHidden flag. Null minRating = no floor.
reviewsMinRating Int?
/// A "rating-only" review is one with no text — excluding those is the
/// same underlying check as "does this review have text", so this is
/// the single field for it (no separate requireText flag).
reviewsExcludeRatingOnly Boolean @default(false)
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)
/// Defaults to false as of Phase 8 (see docs/phase-8-summary.md) — a
/// freshly installed business requires sign-in/account creation before
/// a booking confirms until the owner explicitly opts in via
/// /owner/settings/booking-rules. Existing guest appointments and their
/// access-token links are unaffected either way (see
/// GuestAppointmentAccessToken) — this only gates *new* bookings.
allowGuestBooking Boolean @default(false)
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
}
// ─────────────────────────────────────────────────────────────────────────
// Media (Phase 9) — the single Media Manager library backing every image
// field app-wide (branding, loyalty card, staff photos, service photos,
// gallery/portfolio). Stored on local disk under public/media/ in this
// deployment model (see src/services/media) — deliberately not a cloud
// object-storage integration in v1, kept behind the same service so that's
// swappable later without touching callers.
// ─────────────────────────────────────────────────────────────────────────
model MediaAsset {
id String @id @default(cuid())
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
/// Public path under /media/... (served directly by Next's static file
/// handling for public/ — see docker-compose.local.yaml's media volume).
url String @db.VarChar(500)
originalFilename String?
mimeType String
sizeBytes Int
/// Best-effort, probed at upload time (see src/services/media) — null
/// for anything the probe doesn't recognise, never blocks the upload.
width Int?
height Int?
/// Freeform tag (e.g. "branding", "loyalty", "staff", "service",
/// "portfolio") set by whichever field uploaded it — used to pre-filter
/// the library grid, not an enum, so a new field category never needs a
/// migration.
category String?
uploadedByUserId String?
uploadedByUser User? @relation("MediaAssetUploadedBy", fields: [uploadedByUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
galleryImages BusinessGalleryImage[]
@@index([businessId, category])
}
/// One row per image in either the Home hero gallery or the Portfolio tab
/// — a single model/service for both rather than two near-duplicate
/// gallery features (see src/services/gallery). `imageUrl` is denormalized
/// (same "just a URL" pattern as every other image field in this schema)
/// so deleting the underlying MediaAsset never breaks a live gallery row;
/// `mediaAssetId` is only kept for the library UI to know which rows a
/// given asset is used in.
model BusinessGalleryImage {
id String @id @default(cuid())
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
placement GalleryPlacement
mediaAssetId String?
mediaAsset MediaAsset? @relation(fields: [mediaAssetId], references: [id], onDelete: SetNull)
imageUrl String @db.VarChar(500)
/// Portfolio-only — powers the category badge filter pills. Ignored for
/// HERO placement.
category String?
caption String?
displayOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([businessId, placement, displayOrder])
}
// ─────────────────────────────────────────────────────────────────────────
// Reviews (Phase 9) — synced from Google Places, never fetched client-side
// (see src/services/reviews/review-sync.service.ts, the 24h cron job at
// /api/cron/sync-google-reviews). The pre-existing ReviewsProvider
// abstraction (src/services/reviews/reviews.service.ts) reads from this
// table instead of demo data; callers (Home) don't change.
// ─────────────────────────────────────────────────────────────────────────
model Review {
id String @id @default(cuid())
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
/// Google's own review id — the idempotency key for upsert-on-sync.
externalId String
authorName String
authorPhotoUrl String?
rating Int
text String? @db.Text
/// Google's own pre-formatted "2 weeks ago" string — displayed as-is
/// rather than recomputed, since Google doesn't return an exact
/// timestamp for every review.
relativeTimeDescription String?
publishedAt DateTime?
/// Owner-controlled per-review visibility — see MANAGE_REVIEWS. Applied
/// on top of BusinessSettings' reviewsMinRating/reviewsExcludeRatingOnly
/// policy, not instead of it.
isHidden Boolean @default(false)
/// When the sync job last saw this review — lets a future "remove
/// reviews Google stopped returning" sweep distinguish stale rows
/// without deleting anything today.
fetchedAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([businessId, externalId])
@@index([businessId, isHidden])
}
// ─────────────────────────────────────────────────────────────────────────
// Installation
// ─────────────────────────────────────────────────────────────────────────
/// Single source of truth for "has this deployment been set up yet" — see
/// src/lib/installation/installation-state.ts, the only place this is
/// read. Deliberately not inferred from "does Business have any rows" —
/// an explicit status column survives a partially-failed install cleanly.
///
/// v1 expects exactly one row ever, enforced by a fixed, well-known `id`
/// rather than a separate lock table: the installer's transaction starts
/// with `create({ id: "singleton", status: INSTALLING })` as its very
/// first statement, so a concurrent second install attempt blocks on
/// that same primary-key insert and then fails with a unique-constraint
/// violation once the first transaction commits — no read-then-write
/// race window. If the winning transaction fails partway through, the
/// whole transaction (including this row) rolls back, so a retry sees
/// "not installed" again rather than a stuck INSTALLING/FAILED row — see
/// src/features/installation/actions/install.ts.
model ApplicationInstallation {
id String @id @default(cuid())
status InstallationStatus @default(NOT_INSTALLED)
installedAt DateTime?
installedVersion String?
initialAdminUserId String?
primaryBusinessId String?
/// Small, non-secret metadata only (e.g. which starter-service option
/// was chosen) — never the installation token, never credentials.
metadata Json?
failureReason String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ─────────────────────────────────────────────────────────────────────────
// Auth (Auth.js / NextAuth v5 + Prisma adapter standard shape)
// ─────────────────────────────────────────────────────────────────────────
/// The single identity table for every human in the system — customers,
/// staff, and owners all authenticate as a User; role/permission comes
/// from whichever profile (CustomerProfile / StaffProfile) hangs off it.
/// A staff member who is also an owner is one User with one StaffProfile
/// whose Role carries the extra permissions — never two accounts.
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
/// E.164 format. Nullable + unique now so phone-based login (brief:
/// "design so phone login can easily be added later") is additive.
phone String? @unique
phoneVerified DateTime?
image String?
/// null for OAuth-only users (no password set).
passwordHash String?
/// Platform-level flag, separate from the business Role/Permission
/// model — out of scope to exercise in Phase 2, but the brief's "Future
/// Roles" note calls out a platform administrator explicitly.
isSuperAdmin Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
customerProfile CustomerProfile?
staffProfile StaffProfile?
receivedNotifications Notification[] @relation("NotificationRecipientUser")
notificationPreferences NotificationPreference[]
pushSubscriptions PushSubscription[]
/// Whoever actually cancelled an appointment — the customer themselves,
/// or the staff member who did it on their behalf.
cancelledAppointments Appointment[] @relation("AppointmentCancelledBy")
/// Whoever initiated a booking — see Appointment.createdByUserId.
createdAppointments Appointment[] @relation("AppointmentCreatedBy")
/// Owner-side: invitations this user has sent (see StaffInvitation).
sentStaffInvitations StaffInvitation[] @relation("StaffInvitationInvitedBy")
/// Phase 9 — Media Manager uploads attributed to this user, purely for
/// provenance (see MediaAsset). Not used for any permission check.
uploadedMediaAssets MediaAsset[] @relation("MediaAssetUploadedBy")
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// ─────────────────────────────────────────────────────────────────────────
// People
// ─────────────────────────────────────────────────────────────────────────
/// userId is optional so a guest booking can create a CustomerProfile with
/// no account at all. Deliberately NOT unique-constrained on email/phone —
/// guest duplicate-detection is an explicit open question in
/// docs/implementation-plan.md §15 (Question 4); a hard constraint here
/// would force a premature answer and break repeat guest bookings under
/// a shared household email/phone.
model CustomerProfile {
id String @id @default(cuid())
userId String? @unique
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
firstName String
lastName String
email String
phone String?
isGuest Boolean @default(true)
preferredBarberId String?
preferredBarber StaffProfile? @relation("PreferredBarber", fields: [preferredBarberId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
appointments Appointment[]
loyaltyAccounts LoyaltyAccount[]
notes CustomerNote[]
receivedNotifications Notification[] @relation("NotificationRecipientCustomer")
pushSubscriptions PushSubscription[]
@@index([email])
@@index([phone])
@@index([preferredBarberId])
}
model StaffProfile {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
roleId String
role Role @relation(fields: [roleId], references: [id])
bio String? @db.Text
status StaffStatus @default(ACTIVE)
isBookableOnline Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
services StaffService[]
workingHours WorkingHours[]
breaks Break[]
timeOff TimeOff[]
appointments Appointment[]
preferredByCustomers CustomerProfile[] @relation("PreferredBarber")
authoredLoyaltyTransactions LoyaltyTransaction[] @relation("LoyaltyTransactionAuthor")
authoredCustomerNotes CustomerNote[] @relation("CustomerNoteAuthor")
redeemedLoyaltyRewards LoyaltyReward[] @relation("LoyaltyRewardRedeemedBy")
/// Set when this profile was created via the owner "Add Staff" flow
/// rather than seeded directly — see StaffInvitationService.
invitation StaffInvitation?
/// At most one connected Google account per staff member — see
/// StaffCalendarConnection and src/services/calendar.
calendarConnection StaffCalendarConnection?
@@index([businessId])
}
/// businessId-scoped so future multi-business use never leaks one
/// business's custom roles into another's role picker.
model Role {
id String @id @default(cuid())
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
name String
/// true for the seeded Barber/Owner roles; false for anything an owner
/// creates later (e.g. Receptionist) — lets the UI protect system roles
/// from deletion without a separate table.
isSystem Boolean @default(true)
createdAt DateTime @default(now())
permissions RolePermission[]
staff StaffProfile[]
@@unique([businessId, name])
}
model RolePermission {
id String @id @default(cuid())
roleId String
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
permission Permission
@@unique([roleId, permission])
}
/// Created immediately alongside the StaffProfile/User when an owner adds a
/// staff member (see StaffInvitationService) — the profile is usable right
/// away (it already has a role, can appear on the calendar, etc.); this row
/// only tracks the "set your own password" step, so the UI can show
/// Pending vs Accepted without gating functionality on it. No real email
/// delivery this phase — `token` is surfaced as a copy-link for the owner
/// to send manually, same documented limitation as Phase 5's loyalty
/// redemption note (see docs/phase-6-summary.md).
model StaffInvitation {
id String @id @default(cuid())
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
staffId String @unique
staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade)
email String
token String @unique
invitedByUserId String?
invitedByUser User? @relation("StaffInvitationInvitedBy", fields: [invitedByUserId], references: [id], onDelete: SetNull)
expiresAt DateTime
acceptedAt DateTime?
createdAt DateTime @default(now())
@@index([businessId])
}
/// A staff member's own connected Google account for one-way appointment
/// sync (business → Google Calendar) — entirely separate from Google
/// sign-in (Account/next-auth): a barber may authenticate with email and
/// still connect a *different* Google account here, or vice versa. See
/// src/services/calendar/calendar-sync.service.ts.
model StaffCalendarConnection {
id String @id @default(cuid())
staffId String @unique
staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade)
provider CalendarProvider @default(GOOGLE)
externalAccountId String
accountEmail String
/// AES-256-GCM ciphertext (base64: iv + authTag + ciphertext) — see
/// src/lib/security/token-encryption.ts. CALENDAR_TOKEN_ENCRYPTION_KEY
/// is the only key that can decrypt these; never logged, never
/// returned to the client, never readable by the owner (see
/// StaffCalendarConnection's owner-facing projection in
/// calendar-sync.service.ts, which only ever selects the non-token
/// columns).
encryptedAccessToken String? @db.Text
encryptedRefreshToken String @db.Text
tokenExpiresAt DateTime?
selectedCalendarId String
selectedCalendarName String
connectedAt DateTime @default(now())
lastSyncAt DateTime?
lastSyncError String? @db.Text
revokedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
events AppointmentCalendarEvent[]
}
/// One row per (appointment, connection) pair the appointment has ever
/// been synced to — the unique constraint below is what makes sync
/// idempotent (an existing row means "update the same event", not
/// "create a new one").
model AppointmentCalendarEvent {
id String @id @default(cuid())
appointmentId String
appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
connectionId String
connection StaffCalendarConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
providerEventId String
providerCalendarId String
status CalendarSyncStatus @default(SYNCED)
lastSyncedAt DateTime @default(now())
lastSyncError String? @db.Text
/// Bumped on every appointment mutation that should trigger a re-push
/// (reschedule, service change) — lets the sync worker distinguish
/// "already reflects the latest appointment state" from "needs an
/// update" without re-diffing full event content each time.
sourceVersion Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([appointmentId, connectionId])
@@index([connectionId])
}
// ─────────────────────────────────────────────────────────────────────────
// 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
/// Phase 9 — optional service photo, set via the Media Manager.
imageUrl String?
durationMinutes Int
/// Standard SQL DECIMAL — supported natively on both Postgres and MySQL.
price Decimal @db.Decimal(10, 2)
bufferBeforeMinutes Int @default(0)
bufferAfterMinutes Int @default(0)
isActive Boolean @default(true)
allowOnlineBooking Boolean @default(true)
displayOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
staff StaffService[]
appointmentServices AppointmentService[]
loyaltyEligibleFor LoyaltyEligibleService[]
loyaltyProgrammesUsingAsFreeReward LoyaltyProgramme[]
@@index([businessId])
@@index([categoryId])
}
/// Which staff can perform which service — the brief explicitly requires
/// barber-specific service availability. An optional price/duration
/// override supports a senior barber charging more for the same service.
model StaffService {
id String @id @default(cuid())
staffId String
staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade)
serviceId String
service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade)
priceOverride Decimal? @db.Decimal(10, 2)
durationOverrideMinutes Int?
@@unique([staffId, serviceId])
}
// ─────────────────────────────────────────────────────────────────────────
// Scheduling (availability inputs — see docs/implementation-plan.md §14)
// ─────────────────────────────────────────────────────────────────────────
model WorkingHours {
id String @id @default(cuid())
staffId String
staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade)
dayOfWeek Weekday
isOff Boolean @default(false)
startMinutes Int?
endMinutes Int?
@@unique([staffId, dayOfWeek])
}
/// A recurring break (e.g. daily lunch) can apply to several days, so the
/// day list is a join table (BreakDay) rather than a scalar array column —
/// MySQL has no native array type, and this schema is meant to survive a
/// provider swap without a redesign.
model Break {
id String @id @default(cuid())
staffId String
staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade)
label String
startMinutes Int
endMinutes Int
createdAt DateTime @default(now())
days BreakDay[]
@@index([staffId])
}
model BreakDay {
id String @id @default(cuid())
breakId String
break Break @relation(fields: [breakId], references: [id], onDelete: Cascade)
dayOfWeek Weekday
@@unique([breakId, dayOfWeek])
}
model TimeOff {
id String @id @default(cuid())
staffId String
staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade)
startDate DateTime @db.Date
endDate DateTime @db.Date
reason String?
status TimeOffStatus @default(APPROVED)
/// Both null = blocks the whole day, every day in [startDate, endDate]
/// (the original, pre-Phase-5 behaviour — every existing row has both
/// null). When set, this row instead blocks only [startMinutes,
/// endMinutes] on each day in range, same as a break. This is also how
/// Phase 5's "Block Time" (a one-off slot like a meeting or personal
/// break) is represented — a TimeOff row with startDate = endDate and a
/// short reason — rather than inventing a separate blocked-time model;
/// see docs/phase-5-summary.md.
startMinutes Int?
endMinutes Int?
createdAt DateTime @default(now())
@@index([staffId, startDate, endDate])
}
// ─────────────────────────────────────────────────────────────────────────
// Booking
// ─────────────────────────────────────────────────────────────────────────
model Appointment {
id String @id @default(cuid())
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
customerId String
customer CustomerProfile @relation(fields: [customerId], references: [id], onDelete: Cascade)
staffId String
staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Restrict)
status AppointmentStatus @default(CONFIRMED)
/// Stored as UTC instants; the business's `timezone` (see Business model)
/// is applied at the presentation/query layer, so DST transitions are a
/// rendering concern, not a storage one.
startAt DateTime
endAt DateTime
notes String? @db.Text
/// Snapshot of the amount deducted by a redeemed loyalty reward, if any —
/// see LoyaltyService.redeemReward. Null = no reward applied. The
/// original AppointmentService.priceAtBooking rows are never rewritten;
/// the payable total is subtotal (sum of those) minus this value,
/// computed at read time rather than also stored, so there is exactly
/// one place a future price/discount bug could hide, not two.
discountAmount Decimal? @db.Decimal(10, 2)
/// Cancellation is a status transition, never a delete — these three
/// fields are only ever set together, when status becomes CANCELLED.
cancellationReason String?
cancelledAt DateTime?
cancelledByUserId String?
cancelledByUser User? @relation("AppointmentCancelledBy", fields: [cancelledByUserId], references: [id], onDelete: SetNull)
/// Who initiated this booking — the customer themselves if self-service
/// (or null for a guest with no account), or the staff member if created
/// on the customer's behalf (Phase 5 "Book for customer"/"Follow-up").
/// Purely provenance; never used for permission checks (ownership is via
/// customerId/staffId, not this field).
createdByUserId String?
createdByUser User? @relation("AppointmentCreatedBy", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
services AppointmentService[]
loyaltyTransactions LoyaltyTransaction[]
notifications Notification[]
/// Almost always zero or one row — a reward is applied to at most one
/// appointment (see LoyaltyReward.appliedToAppointmentId) — but modelled
/// as the array Prisma requires for a one-to-many back-relation.
appliedLoyaltyRewards LoyaltyReward[]
guestAccessToken GuestAppointmentAccessToken?
calendarEvents AppointmentCalendarEvent[]
@@index([staffId, startAt])
@@index([businessId, startAt])
@@index([customerId])
}
/// A guest (no linked User — see CustomerProfile.userId) proves they own an
/// appointment by presenting the raw token embedded in their booking
/// confirmation link, not by knowing the appointment's id alone (a cuid is
/// unguessable but was never actually a secret — Phase 4 documented this as
/// a deliberate, temporary gap; closed here — see
/// src/services/booking/guest-access.service.ts and docs/phase-7-summary.md).
/// Only the SHA-256 hash is ever persisted; the raw token exists just long
/// enough to embed in a link (confirmation page, and later, email) and is
/// never logged. One row per appointment — (re-)issuing overwrites the
/// previous hash, so an old copy of the raw token stops working.
model GuestAppointmentAccessToken {
id String @id @default(cuid())
appointmentId String @unique
appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
tokenHash String @unique
expiresAt DateTime
revokedAt DateTime?
lastUsedAt DateTime?
createdAt DateTime @default(now())
@@index([expiresAt])
}
/// One appointment can carry multiple services (the booking flow's "Add
/// another service" step). price/duration are snapshotted at booking time
/// so a later Service price change never rewrites booking history.
model AppointmentService {
id String @id @default(cuid())
appointmentId String
appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
serviceId String
service Service @relation(fields: [serviceId], references: [id], onDelete: Restrict)
priceAtBooking Decimal @db.Decimal(10, 2)
durationAtBookingMinutes Int
@@index([appointmentId])
}
// ─────────────────────────────────────────────────────────────────────────
// Loyalty — ledger-based by design (brief: "never simply store a stamp
// count"; every stamp add/remove/redemption is its own row).
// ─────────────────────────────────────────────────────────────────────────
/// Phase 10 — a small, curated set of card fonts with zero extra network
/// cost (Tailwind's own built-in generic font-family stacks), not an
/// arbitrary CSS font-family string — see loyalty-config-form.tsx's
/// IconPicker-style font Select.
enum LoyaltyCardFont {
SANS
SERIF
MONO
}
/// Phase 10 — an enum, not a free number, so a bad value can never break
/// card layout.
enum LoyaltyCardTextSize {
SM
MD
LG
}
/// Phase 10 — resolves the "stamps required" ambiguity the brief called
/// out: does the reward unlock only once every stamp position is filled,
/// or is the final position itself the reward (earned one stamp early)?
/// See resolveRewardThreshold() in src/services/loyalty/loyalty.service.ts
/// — the one place this is interpreted; automatic stamps, manual stamps,
/// the customer card, and progress text all read through it, so none of
/// them can disagree with each other.
enum RewardPositionMode {
AFTER_ALL_STAMPS
FINAL_SLOT_IS_REWARD
}
model LoyaltyProgramme {
id String @id @default(cuid())
businessId String @unique
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
isEnabled Boolean @default(false)
/// Capped at 10 (see the CHECK constraint added in migration
/// 20260805000000_phase10_loyalty_card_and_permission_fixes) — enforced
/// identically in validateProgrammeConfig, the owner form, and here.
stampsRequired Int @default(10)
/// See RewardPositionMode's own doc comment.
rewardPosition RewardPositionMode @default(AFTER_ALL_STAMPS)
rewardType RewardType @default(PERCENTAGE)
/// Percentage points or a fixed-amount value depending on rewardType;
/// null when rewardType is FREE_SERVICE.
rewardValue Decimal? @db.Decimal(10, 2)
rewardLabel String?
maxRewardValue Decimal? @db.Decimal(10, 2)
/// Which service is free when rewardType is FREE_SERVICE — null for the
/// other two reward types. See LoyaltyService.validateProgrammeConfig for
/// the "must reference an active service" rule.
freeServiceId String?
freeService Service? @relation(fields: [freeServiceId], references: [id], onDelete: SetNull)
autoApplyReward Boolean @default(true)
requiresStaffApproval Boolean @default(false)
stampsExpireAfterDays Int?
rewardExpiresAfterDays Int?
earnOnCancelled Boolean @default(false)
earnOnNoShow Boolean @default(false)
cardBackgroundColor String?
cardBackgroundImageUrl String?
stampIcon String?
termsLine String?
/// Phase 11 — replaces the hardcoded "Loyalty Card" title shown top-left
/// on the card. Null/empty falls back to "Loyalty Card", same as before
/// this was configurable.
cardTitleText String?
/// Card-wide — applies to all card text, not just the title. See
/// LoyaltyCardFont's doc comment for why this is card-wide rather than a
/// control per element.
cardFont LoyaltyCardFont @default(SANS)
cardTitleTextSize LoyaltyCardTextSize @default(MD)
cardTitleTextColor String?
cardBorderColor String?
cardMemberNumberColor String?
updatedAt DateTime @updatedAt
/// An empty eligibleServices/eligibleStaff list means "all services /
/// all staff are eligible" — an explicit row is only needed to *restrict*
/// eligibility, so the common "loyalty applies to everything" case
/// requires zero extra rows.
eligibleServices LoyaltyEligibleService[]
eligibleStaff LoyaltyEligibleStaff[]
accounts LoyaltyAccount[]
}
model LoyaltyEligibleService {
id String @id @default(cuid())
loyaltyProgrammeId String
loyaltyProgramme LoyaltyProgramme @relation(fields: [loyaltyProgrammeId], references: [id], onDelete: Cascade)
serviceId String
service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade)
@@unique([loyaltyProgrammeId, serviceId])
}
model LoyaltyEligibleStaff {
id String @id @default(cuid())
loyaltyProgrammeId String
loyaltyProgramme LoyaltyProgramme @relation(fields: [loyaltyProgrammeId], references: [id], onDelete: Cascade)
staffId String
@@unique([loyaltyProgrammeId, staffId])
}
model LoyaltyAccount {
id String @id @default(cuid())
customerId String
customer CustomerProfile @relation(fields: [customerId], references: [id], onDelete: Cascade)
loyaltyProgrammeId String
loyaltyProgramme LoyaltyProgramme @relation(fields: [loyaltyProgrammeId], references: [id], onDelete: Cascade)
/// Cached running total for fast reads. Always derived from — and
/// reconciled against — `transactions`; the ledger, not this column, is
/// the source of truth (brief requirement).
currentStamps Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
transactions LoyaltyTransaction[]
rewards LoyaltyReward[]
@@unique([customerId, loyaltyProgrammeId])
}
model LoyaltyTransaction {
id String @id @default(cuid())
loyaltyAccountId String
loyaltyAccount LoyaltyAccount @relation(fields: [loyaltyAccountId], references: [id], onDelete: Cascade)
type LoyaltyTransactionType
/// +1 for a stamp, -1 for a staff correction, etc.
amount Int
reason String?
appointmentId String?
appointment Appointment? @relation(fields: [appointmentId], references: [id], onDelete: SetNull)
authoredByStaffId String?
authoredByStaff StaffProfile? @relation("LoyaltyTransactionAuthor", fields: [authoredByStaffId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
@@index([loyaltyAccountId])
}
/// A completed/redeemed reward is recorded here *before* the account's
/// active stamp count resets, per the brief's explicit requirement.
model LoyaltyReward {
id String @id @default(cuid())
loyaltyAccountId String
loyaltyAccount LoyaltyAccount @relation(fields: [loyaltyAccountId], references: [id], onDelete: Cascade)
rewardType RewardType
value Decimal? @db.Decimal(10, 2)
label String
status RewardStatus @default(AVAILABLE)
appliedToAppointmentId String?
appliedToAppointment Appointment? @relation(fields: [appliedToAppointmentId], references: [id], onDelete: SetNull)
redeemedAt DateTime?
redeemedByStaffId String?
redeemedByStaff StaffProfile? @relation("LoyaltyRewardRedeemedBy", fields: [redeemedByStaffId], references: [id], onDelete: SetNull)
expiresAt DateTime?
createdAt DateTime @default(now())
notifications Notification[]
@@index([loyaltyAccountId])
}
// ─────────────────────────────────────────────────────────────────────────
// Notifications (Phase 7) — a Notification is one message to one
// recipient (the "what happened, to whom"); a NotificationDelivery is one
// attempt to get that message to them over one channel (email/push/
// in-app). Split for the same reason AppointmentService snapshots price
// separately from Service — a single message commonly needs more than one
// delivery attempt/channel with independent status, without duplicating
// the rendered content. See src/services/notifications/notification.service.ts.
// ─────────────────────────────────────────────────────────────────────────
model Notification {
id String @id @default(cuid())
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
/// Set for staff/owner/registered-customer recipients — resolves
/// NotificationPreference and powers the in-app notification centre.
/// Null for a guest customer (no User to attach to).
recipientUserId String?
recipientUser User? @relation("NotificationRecipientUser", fields: [recipientUserId], references: [id], onDelete: Cascade)
/// Set whenever the recipient IS a customer (guest or registered) —
/// lets a guest-directed notification render/deep-link correctly even
/// with no recipientUserId.
recipientCustomerId String?
recipientCustomer CustomerProfile? @relation("NotificationRecipientCustomer", fields: [recipientCustomerId], references: [id], onDelete: Cascade)
type NotificationType
appointmentId String?
appointment Appointment? @relation(fields: [appointmentId], references: [id], onDelete: SetNull)
loyaltyRewardId String?
loyaltyReward LoyaltyReward? @relation(fields: [loyaltyRewardId], references: [id], onDelete: SetNull)
subject String?
/// Canonical rendered plain-text content — the in-app notification body
/// and the email plain-text fallback both read from here; the HTML email
/// template (see src/services/notifications/templates) renders its own
/// markup from the same underlying data, not from this string.
bodyText String @db.Text
/// Relative path (e.g. "/appointments/xyz?token=..." for a guest) —
/// never an absolute URL, so it works whether the notification is opened
/// from an email (base URL prepended at send time), the installed PWA,
/// or the in-app centre.
deepLinkPath String?
/// Prevents duplicate creation from a retried/duplicate domain call —
/// e.g. "booking-confirmed:{appointmentId}" or
/// "reminder-24h:{appointmentId}". A second attempt to create the same
/// logical notification is a no-op (see NotificationService.notify).
idempotencyKey String @unique
/// Only meaningful for a recipientUserId notification with an IN_APP
/// delivery — the in-app centre's read/unread state.
readAt DateTime?
createdAt DateTime @default(now())
deliveries NotificationDelivery[]
@@index([businessId, createdAt])
@@index([recipientUserId, readAt])
@@index([recipientCustomerId])
@@index([appointmentId])
}
model NotificationDelivery {
id String @id @default(cuid())
notificationId String
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
channel NotificationChannel
status NotificationDeliveryStatus @default(PENDING)
/// Null = process as soon as the background worker picks it up; set for
/// a scheduled appointment reminder.
scheduledFor DateTime?
attemptCount Int @default(0)
lastAttemptedAt DateTime?
providerName String?
providerMessageId String?
/// A short, safe-to-display summary only — never the provider's raw
/// error payload, which can include request details worth not logging.
failureReason String?
sentAt DateTime?
createdAt DateTime @default(now())
@@unique([notificationId, channel])
@@index([status, scheduledFor])
}
/// Only the few NotificationTypes that are genuinely optional (currently:
/// the two reminder types) ever consult this — required transactional
/// types always send regardless of any row here, by design (see
/// NotificationService, PREFERENCE_CONTROLLED_TYPES). No stored
/// "category" column: which types are preference-controlled is an
/// application-layer policy, not a schema fact, so it can change without
/// a migration.
model NotificationPreference {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type NotificationType
channel NotificationChannel
isEnabled Boolean @default(true)
@@unique([userId, type, channel])
}
/// One browser/device's Web Push subscription — a user can have several
/// (phone + laptop). Scoped to a business (not just a user) because a
/// guest customer, who has no User at all, can still subscribe from
/// whatever device they're on right after booking; recipientUserId is
/// null for that case, matching the same "user OR customer" recipient
/// shape as Notification itself.
model PushSubscription {
id String @id @default(cuid())
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
customerId String?
customer CustomerProfile? @relation(fields: [customerId], references: [id], onDelete: Cascade)
/// The push service's own subscription URL — long (some real-world push
/// services exceed 191 chars), hence the explicit length instead of
/// Prisma's default VARCHAR(191) for a unique-constrained String.
endpoint String @unique @db.VarChar(500)
/// The subscription's own public key/auth secret (from
/// PushSubscription.toJSON().keys in the browser) — needed to encrypt
/// each push payload; not a credential of ours to protect the way
/// EMAIL_SMTP_PASSWORD is, but still never logged (see reminder-worker.ts).
p256dhKey String
authKey String
/// User-facing label so Settings can show "iPhone — Safari" instead of a
/// raw endpoint — best-effort, derived from the User-Agent at subscribe
/// time, not re-verified later.
deviceLabel String?
createdAt DateTime @default(now())
lastUsedAt DateTime?
/// Set when the push service itself reports the subscription is gone
/// (410/404 — see reminder-worker.ts) or the user explicitly
/// unsubscribes; never deleted outright, so "why did this stop
/// receiving pushes" stays answerable.
revokedAt DateTime?
@@index([userId])
@@index([customerId])
}
// ─────────────────────────────────────────────────────────────────────────
// Misc
// ─────────────────────────────────────────────────────────────────────────
model CustomerNote {
id String @id @default(cuid())
customerId String
customer CustomerProfile @relation(fields: [customerId], references: [id], onDelete: Cascade)
authorStaffId String
author StaffProfile @relation("CustomerNoteAuthor", fields: [authorStaffId], references: [id], onDelete: Restrict)
body String @db.Text
createdAt DateTime @default(now())
/// Set automatically whenever body changes — the only edit signal this
/// phase needs (see StaffService and docs/phase-5-summary.md for the
/// note permission model: a staff member may edit/delete only their own
/// note). A full field-level audit trail was judged unnecessary for v1.
updatedAt DateTime @updatedAt
@@index([customerId])
}
/// Generic audit trail for staff/owner actions on sensitive records
/// (permission changes, business setting edits). Not written to by
/// anything in Phase 2 — the table exists now so Phase 4/6 features don't
/// need a migration to start using it.
model AuditLog {
id String @id @default(cuid())
actorUserId String?
action String
entityType String
entityId String
/// `Json` maps to native `jsonb`/`json` on both Postgres and MySQL.
metadata Json?
createdAt DateTime @default(now())
@@index([entityType, entityId])
@@index([actorUserId])
}