This commit is contained in:
Stefan Lazic committed 2026-08-04 09:57:58 +01:00
1 parent 546613e677
commit 4785510144
82 files changed
+4903 -287

No files matched your search

+182 -16
View File
@@ -191,6 +191,31 @@ enum NotificationDeliveryStatus {
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
// ─────────────────────────────────────────────────────────────────────────
@@ -199,11 +224,30 @@ enum NotificationDeliveryStatus {
/// 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
description String? @db.Text
logoUrl String?
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?
/// Used for the PWA manifest icon set — falls back to the same neutral
/// app icon as the favicon when unset.
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?
phone String?
email String?
@@ -317,7 +361,13 @@ model BookingRule {
maxAppointmentsPerStaffPerDay Int?
customerCanChooseStaff Boolean @default(true)
allowGuestBooking 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").
@@ -326,6 +376,46 @@ model BookingRule {
updatedAt DateTime @updatedAt
}
// ─────────────────────────────────────────────────────────────────────────
// 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)
// ─────────────────────────────────────────────────────────────────────────
@@ -438,7 +528,7 @@ model CustomerProfile {
appointments Appointment[]
loyaltyAccounts LoyaltyAccount[]
notes CustomerNote[]
receivedNotifications Notification[] @relation("NotificationRecipientCustomer")
receivedNotifications Notification[] @relation("NotificationRecipientCustomer")
pushSubscriptions PushSubscription[]
@@index([email])
@@ -470,13 +560,16 @@ model StaffProfile {
breaks Break[]
timeOff TimeOff[]
appointments Appointment[]
preferredByCustomers CustomerProfile[] @relation("PreferredBarber")
authoredLoyaltyTransactions LoyaltyTransaction[] @relation("LoyaltyTransactionAuthor")
authoredCustomerNotes CustomerNote[] @relation("CustomerNoteAuthor")
redeemedLoyaltyRewards LoyaltyReward[] @relation("LoyaltyRewardRedeemedBy")
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])
}
@@ -544,6 +637,78 @@ model StaffInvitation {
@@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
// ─────────────────────────────────────────────────────────────────────────
@@ -752,6 +917,7 @@ model Appointment {
/// as the array Prisma requires for a one-to-many back-relation.
appliedLoyaltyRewards LoyaltyReward[]
guestAccessToken GuestAppointmentAccessToken?
calendarEvents AppointmentCalendarEvent[]
@@index([staffId, startAt])
@@index([businessId, startAt])
@@ -984,12 +1150,12 @@ model Notification {
loyaltyRewardId String?
loyaltyReward LoyaltyReward? @relation(fields: [loyaltyRewardId], references: [id], onDelete: SetNull)
subject String?
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
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,
@@ -1036,7 +1202,7 @@ model NotificationDelivery {
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?
failureReason String?
sentAt DateTime?
@@ -1061,7 +1227,7 @@ model NotificationPreference {
type NotificationType
channel NotificationChannel
isEnabled Boolean @default(true)
isEnabled Boolean @default(true)
@@unique([userId, type, channel])
}
@@ -1087,7 +1253,7 @@ model PushSubscription {
/// 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)
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