This commit is contained in:
Stefan Lazic committed 2026-08-03 21:15:24 +01:00
1 parent ec5583f972
commit 546613e677
189 files changed
+9873 -655

No files matched your search

+246 -30
View File
@@ -1,15 +1,23 @@
// NextChair — Phase 2 database schema.
//
// Portability: every model deliberately avoids Postgres-only features
// (native arrays, JSONB-specific operators, LISTEN/NOTIFY) so the
// `datasource.provider` below can later change to "mysql" with a
// `prisma migrate diff` instead of a schema rewrite. Concretely:
// (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"
@@ -19,7 +27,7 @@ generator client {
// Connection URL lives in prisma.config.ts (Prisma 7) and is passed to
// PrismaClient at runtime via a driver adapter — see src/lib/db/prisma.ts.
datasource db {
provider = "postgresql"
provider = "mysql"
}
// ─────────────────────────────────────────────────────────────────────────
@@ -110,26 +118,77 @@ enum LoyaltyTransactionType {
REWARD_REDEEMED
}
enum NotificationCategory {
BOOKING_CONFIRMATION
/// 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_CHANGED
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_AVAILABLE
MARKETING
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
SMS
PUSH
/// Visible in the in-app notification centre — see src/features/notifications.
IN_APP
}
enum NotificationStatus {
/// 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
}
// ─────────────────────────────────────────────────────────────────────────
@@ -173,6 +232,8 @@ model Business {
appointments Appointment[]
loyaltyProgramme LoyaltyProgramme?
staffInvitations StaffInvitation[]
notifications Notification[]
pushSubscriptions PushSubscription[]
}
/// Split from Business (rather than address fields on Business itself) so
@@ -298,8 +359,9 @@ model User {
sessions Session[]
customerProfile CustomerProfile?
staffProfile StaffProfile?
notifications Notification[]
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")
@@ -373,9 +435,11 @@ model CustomerProfile {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
appointments Appointment[]
loyaltyAccounts LoyaltyAccount[]
notes CustomerNote[]
appointments Appointment[]
loyaltyAccounts LoyaltyAccount[]
notes CustomerNote[]
receivedNotifications Notification[] @relation("NotificationRecipientCustomer")
pushSubscriptions PushSubscription[]
@@index([email])
@@index([phone])
@@ -587,6 +651,8 @@ model Break {
createdAt DateTime @default(now())
days BreakDay[]
@@index([staffId])
}
model BreakDay {
@@ -685,12 +751,40 @@ model Appointment {
/// appointment (see LoyaltyReward.appliedToAppointmentId) — but modelled
/// as the array Prisma requires for a one-to-many back-relation.
appliedLoyaltyRewards LoyaltyReward[]
guestAccessToken GuestAppointmentAccessToken?
@@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.
@@ -849,51 +943,173 @@ model LoyaltyReward {
createdAt DateTime @default(now())
notifications Notification[]
@@index([loyaltyAccountId])
}
// ─────────────────────────────────────────────────────────────────────────
// Notifications — abstracted behind category + channel so an SMS/push
// provider is a new NotificationChannel value and a sender implementation,
// never a schema change.
// 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())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
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)
category NotificationCategory
channel NotificationChannel
status NotificationStatus @default(PENDING)
loyaltyRewardId String?
loyaltyReward LoyaltyReward? @relation(fields: [loyaltyRewardId], references: [id], onDelete: SetNull)
subject String?
body String @db.Text
/// 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?
sentAt DateTime?
failureReason 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())
@@index([userId])
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)
category NotificationCategory
type NotificationType
channel NotificationChannel
isEnabled Boolean @default(true)
isEnabled Boolean @default(true)
@@unique([userId, category, channel])
@@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])
}
// ─────────────────────────────────────────────────────────────────────────