Phase 7
This commit is contained in:
189 files changed
+9873
-655
No files matched your search
+246
-30
@@ -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])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user