Files
NextChair/prisma/schema.prisma
T
Stefan Lazic 68a62e02a4
CI / ci (push) Canceled after 0s
Remaining features
Built
No-show flag and block:
Two new numbers under Booking Rules (blank means off).
A symbol appears on the clients list, client page, calendar entries and appointment pages.
Online booking is refused at the block number, and customers are told at the details step.
Staff can still book a blocked customer in, and an owner can reset someone with a two-tap button.
The count resets when they attend, and matches the same email or phone, so a guest can’t dodge it with a new profile.
Email rules: staff and owners get email only for customer cancellations, schedule changes and invitations. Routine events (new booking, reschedule) are push-only. Nobody is told about their own action. A barber reassignment sends one customer email instead of two. Reward expiry sends one warning.
Email redesign: your brand colour, a details card, Add to calendar and View on map links, a preferences link, hidden preview text, and dark mode. Replies now go to the business’s own email.
WhatsApp “due now” queue: a list in time order with a highlighted next one, an owner setting for how many hours ahead (12–72), and a once-a-day push telling each barber how many reminders are waiting.
Monitoring: /api/health now reports whether notifications are actually going out. The worker resets stuck deliveries and alerts the owner on the first setup failure (such as a bad SMTP login). The app emails your operator address about server errors, and the manager pushes that address to each business.
Google scope: now only calendar.app.created. Events go to a calendar the app creates in the staff member’s Google account, called “<Business> bookings”.
2026-10-17 23:10:18 +01:00

1715 lines
68 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
// Barber-facing (cont.)
/// A nudge that some appointments' WhatsApp reminders are due to send by
/// hand — see whatsapp-reminders.service.ts. Push only by default.
STAFF_WHATSAPP_REMINDERS_DUE
}
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
}
enum LegalNameDisplay {
NEXTCHAIR
BUSINESS
BOTH
}
enum LegalContactEmail {
NEXTCHAIR
BUSINESS
}
/// 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[]
holidays BusinessHoliday[]
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?
/// How many hours ahead appointments become "due" in the staff WhatsApp
/// reminders queue (/staff/reminders). See whatsapp-reminders.service.ts.
whatsappReminderLeadHours Int @default(24)
/// Who the public Terms of Service / Privacy Policy pages name as "we" —
/// see src/features/legal/legal-identity.ts. NEXTCHAIR is the default so
/// existing businesses keep today's wording.
legalNameDisplay LegalNameDisplay @default(NEXTCHAIR)
/// Which inbox those pages give for legal/privacy requests. BUSINESS uses
/// Business.email and falls back to NextChair's when that is empty.
legalContactEmail LegalContactEmail @default(NEXTCHAIR)
/// 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.
/// A date range the whole business is closed — bank holidays, annual leave,
/// a refit — on top of the weekly BusinessHours. Both dates are inclusive
/// calendar dates in the business's own timezone (no time component), the
/// same convention as TimeOff. Availability treats every date in range as
/// closed for every staff member; see availability.service.ts.
model BusinessHoliday {
id String @id @default(cuid())
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
name String
startDate DateTime @db.Date
endDate DateTime @db.Date
createdAt DateTime @default(now())
@@index([businessId, startDate, endDate])
}
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)
/// Both null = no-show handling is off. A customer's no-show count is
/// derived (see no-show.service.ts), never stored: NO_SHOW appointments
/// since their last attended (COMPLETED) one or since an owner reset.
/// At/above the flag threshold staff see a warning symbol; at/above the
/// block threshold online booking is refused (staff can still book them).
noShowFlagThreshold Int?
noShowBlockThreshold Int?
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
/// Stamped by /api/cron/process-notifications on every authorised run, so
/// /api/health can tell "the cron stopped" from "nothing to send".
lastCronRunAt DateTime?
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?
/// Deprecated — password auth was replaced by email OTP (see EmailOtp
/// and src/auth.ts's Credentials.authorize). Kept nullable and
/// unremoved purely so historical rows and any direct reads of old data
/// don't break; no code path writes or checks this for authentication
/// anymore. Safe to drop in a future migration once confirmed unused.
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])
}
/// Email OTP (one-time passcode) authentication — replaces password-based
/// sign-in across the app (see src/auth.ts's Credentials.authorize and
/// src/lib/auth/otp.service.ts, the one place that reads/writes this
/// model). One row per email, not one per code: a new request UPSERTs
/// this row, which is what makes "generating a new OTP invalidates any
/// previous active OTP for that email" true by construction rather than
/// something extra to enforce. `codeHash` only — the plain 6-digit code
/// is never persisted anywhere.
///
/// Two timestamps, not one, because two genuinely different flows need
/// this: a plain sign-in verifies and immediately consumes a code in one
/// request (`verifiedAt` and `consumedAt` set together), while the
/// installer and staff-invitation-acceptance flows verify the code in an
/// earlier request (setting `verifiedAt` only) and finalize/consume it in
/// a later one, after their own transaction commits — the code itself
/// can't be re-entered at that point (one-time use), so finalizing keys
/// only off `verifiedAt` being recent and not yet consumed.
model EmailOtp {
email String @id
codeHash String
/// "login" | "register" | "install" | "staff-invite" — audit/debugging
/// context only, never branched on for verification logic.
purpose String
expiresAt DateTime
/// Incorrect-code attempts against this row — capped independently of
/// (and in addition to) the request/verify rate limits in
/// otp.service.ts, so exhausting attempts on one code can't be worked
/// around just by calling verify faster.
attempts Int @default(0)
/// Set once the correct code is entered. Not itself proof of a
/// completed sign-in — see `consumedAt`.
verifiedAt DateTime?
/// Set once this verification has actually been used to create a
/// session — enforces one-time use. A row with `verifiedAt` set but
/// `consumedAt` still null is "proven but not yet spent", the
/// installer/invite-accept window described above.
consumedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ─────────────────────────────────────────────────────────────────────────
// 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)
/// An owner's "forgive" — no-shows before this moment no longer count
/// towards the flag/block thresholds. See no-show.service.ts.
noShowResetAt DateTime?
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
/// True when the connection was made with the narrow calendar.app.created
/// scope: selectedCalendarId is a secondary calendar this app created, and
/// there is no calendar picker (the scope can't list the user's calendars).
/// False = a pre-existing connection with the broader scopes.
appCreatedCalendar Boolean @default(false)
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)
/// Whether the customer picked THIS specific staff member when they
/// originally booked, as opposed to "any available barber" (the system
/// then auto-assigns one — see BookingService.createBooking). Set once
/// at creation and never rewritten by a later reassignment — it
/// describes the ORIGINAL booking, used to decide whether a later
/// staff reassignment is worth emailing the customer about (see
/// notifyBookingBarberChanged): someone who never expressed a
/// preference is less likely to care who they end up with. Defaults to
/// true so every appointment created before this field existed keeps
/// notifying on reassignment exactly as it did before.
staffSelectedByCustomer Boolean @default(true)
/// When staff last tapped "send on WhatsApp" for this booking's reminder —
/// the manual, free route (see lib/messaging/whatsapp.ts), so this records
/// that the message was OPENED for sending, not that it was delivered
/// (the app can't know). Exists so two people sharing a reminders list
/// don't text the same customer twice. Cleared whenever the booking's
/// time or barber changes (rescheduleAppointment): a reminder for the old
/// slot says nothing about the new one.
whatsappReminderSentAt DateTime?
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])
}