Phase 8
This commit is contained in:
82 files changed
+4903
-287
No files matched your search
+182
-16
@@ -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
|
||||
|
||||
Reference in new issue
Block a user