CI / ci (push) Canceled after 0s
Core logic: types.ts (defaults), notification.service.ts (skipEmail + dedup), booking-notifications.ts, loyalty-notifications.ts, reminder-scheduling.ts, booking.service.ts + scheduling.service.ts (staff-selection capture), features/owner/actions/appointments.ts. Schema: prisma/schema.prisma + new migration 20260912165903_add_staff_selected_by_customer. UI: both notification-preferences pages (customer, staff) now use the same default logic as the actual send decision. Tests: notification.service.integration.test.ts, booking-notifications.integration.test.ts, reminder-scheduling.integration.test.ts, loyalty-notifications.integration.test.ts, plus a new createTestAppointment fixture. Docs: docs/notifications.md rewritten to match. Default changes Exactly as specified — full table is in docs/notifications.md. In short: STAFF_NEW_APPOINTMENT, STAFF_APPOINTMENT_RESCHEDULED, STAFF_APPOINTMENT_REMINDER, OWNER_NEW_BOOKING, OWNER_NO_SHOW now default off; everything else that was already toggleable is untouched. No longer email (fixed, not a toggle) BOOKING_COMPLETED, LOYALTY_STAMP_ADDED, LOYALTY_REWARD_REDEEMED — always. BOOKING_BARBER_CHANGED — only when the customer originally booked "any available barber." The customer's 2-hour reminder is removed entirely (24h stays); staff keeps both, just off by default. All via a new skipEmail flag on notify(), checked in one place — no new preference rows for these. Deduplication One new EQUIVALENCE_GROUPS map in notification.service.ts (STAFF_NEW_APPOINTMENT>OWNER_NEW_BOOKING, STAFF_APPOINTMENT_CANCELLED>OWNER_BOOKING_CANCELLED). Inside notify(), before creating deliveries, it checks for an already-created equivalent notification for the same (businessId, appointmentId, recipientUserId); if found and at-least-as-relevant, the new one gets zero deliveries on every channel (row still created for audit trail). Only listed types participate, so OWNER_NO_SHOW and any future actionable owner type are never touched.
1636 lines
64 KiB
Plaintext
1636 lines
64 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?
|
|
/// 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)
|
|
|
|
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)
|
|
|
|
/// 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)
|
|
|
|
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])
|
|
}
|