// 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) 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]) }