// This is your Prisma schema file, // learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } enum UserRole { admin supervisor staff user } enum NotificationPreference { email whatsapp both } enum RegistrationStatus { pending confirmed partial_paid paid cancelled } enum CashupStatus { open closed } enum CashupAction { closed quick_closed reopened } enum EventCostType { once_off per_item } enum SecurityEventType { login password_changed password_reset two_factor_enabled two_factor_disabled } enum TwoFactorMethod { email whatsapp } model User { id String @id @default(uuid()) name String email String @unique password String createdAt DateTime @default(now()) isActive Boolean @default(true) phoneNumber String? @unique updatedAt DateTime @updatedAt role UserRole @default(user) failedAttempts Int @default(0) lockUntil DateTime? tokenVersion Int @default(0) notificationPreference NotificationPreference @default(email) twoFactorEnabled Boolean @default(false) twoFactorMethod TwoFactorMethod? registrations Registration[] payments Payment[] paymentsRecorded Payment[] @relation("PaymentRecordedBy") tickets Ticket[] ticketScans TicketUsage[] passwordResets PasswordReset[] Event Event[] FormDraft FormDraft[] eventsClosed Event[] @relation("EventClosedBy") eventsReopened Event[] @relation("EventReopenedBy") cashupsPerformed EventCashup[] notifyForEvents Event[] @relation("EventNotifyRecipients") personCashCountsFor EventCashupPersonCount[] @relation("EventCashupPersonCountFor") personCashCountsEntered EventCashupPersonCount[] @relation("EventCashupPersonCountEnteredBy") securityEvents SecurityEvent[] adminAuditLogs AdminAuditLog[] @relation("AdminAuditActor") couponsCreated Coupon[] @relation("CouponCreatedBy") couponAccess CouponUser[] couponRedemptions CouponRedemption[] @relation("CouponRedemptionUser") couponsApplied CouponRedemption[] @relation("CouponRedemptionAppliedBy") attendeeProfiles Attendee[] heldTickets Ticket[] @relation("TicketHolder") waitlistEntries WaitlistEntry[] scanLocks ScanLock[] @relation("ScanLockOwner") transfersTo TicketTransfer[] @relation("TransferRecipient") transfersBy TicketTransfer[] @relation("TransferActor") ledGroups AllocationGroup[] @relation("AllocationGroupLeader") feedbackInvites FeedbackInvite[] feedbackGiven FeedbackResponse[] impersonationsDone ImpersonationSession[] @relation("ImpersonationAdmin") transferRequests TicketTransferRequest[] @relation("TransferRequester") impersonatedIn ImpersonationSession[] @relation("ImpersonationTarget") scanLocksForced ScanLock[] @relation("ScanLockForcedBy") syncReportsDone OfflineSyncReport[] @relation("SyncReportReviewer") twoFactorChallenges TwoFactorChallenge[] trustedDevices TrustedDevice[] } model Event { id String @id @default(uuid()) title String description String? startDate DateTime endDate DateTime registrationDeadline DateTime? goLiveAt DateTime @default(now()) price Float picture String? isActive Boolean @default(true) isHidden Boolean @default(false) requiresAuth Boolean @default(true) requiresRegistration Boolean @default(true) // Lets a payer name each person on a group registration (one named ticket each). allowNamedAttendees Boolean @default(false) // Youth event: every attendee gives a date of birth, and a parent/guardian must give // consent for anyone under 18 before they can be checked in. requiresParentalConsent Boolean @default(false) consentText String? consentTextVersion Int @default(1) consentCollectMedical Boolean @default(false) consentCollectEmergency Boolean @default(false) consentCollectPhoto Boolean @default(false) // Waitlist for sold-out options: when places free up, the next person is offered // them for waitlistOfferHours. waitlistPartialOffers: offer what's free even if it's // fewer places than asked for (otherwise skip to the next person who fits). waitlistEnabled Boolean @default(false) allowTicketTransfer Boolean @default(true) collectGender Boolean @default(false) // Sponsor a spot: public donations into a bursary pool for this event. allowSponsorships Boolean @default(false) sponsorshipGoalSpots Int? showSponsorshipProgress Boolean @default(true) feedbackForm FeedbackForm? analyticsEvents AnalyticsEvent[] analyticsDaily AnalyticsDaily[] allocationSets AllocationSet[] waitlistOfferHours Int @default(24) waitlistPartialOffers Boolean @default(true) contactName String? contactPhone String? contactEmail String? location String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt createdById String? createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) redirectUrl String? @unique eventOptions EventOption[] registrations Registration[] payments Payment[] tickets Ticket[] attachments EventAttachment[] form EventForm? Section Section[] cashupStatus CashupStatus @default(open) cashupDraft Json? closedAt DateTime? closedById String? closedBy User? @relation("EventClosedBy", fields: [closedById], references: [id], onDelete: SetNull) reopenedAt DateTime? reopenedById String? reopenedBy User? @relation("EventReopenedBy", fields: [reopenedById], references: [id], onDelete: SetNull) costs EventCost[] cashups EventCashup[] personCashCounts EventCashupPersonCount[] // Users who should receive registration/payment/daily-summary notifications for this // event. Falls back to `createdBy` when empty (see backend/src/utils/notifications.js). notifyRecipients User[] @relation("EventNotifyRecipients") coupons CouponEvent[] consentRequests ConsentRequest[] waitlistEntries WaitlistEntry[] scanLocks ScanLock[] @@index([createdById]) @@index([closedById]) @@index([reopenedById]) } model EventOption { id String @id @default(uuid()) eventId String name String price Float isMainTicket Boolean stockLimit Int @default(0) // Applied automatically for Staff and above instead of `price` (when lower). Null = no staff price. staffPrice Float? event Event @relation(fields: [eventId], references: [id], onDelete: Restrict) registrationOptions RegistrationOption[] earlyBirdTiers EarlyBirdTier[] SectionOption SectionOption[] variants OptionVariant[] costs EventCost[] couponScopes CouponOption[] waitlistEntries WaitlistEntry[] @@index([eventId]) } model EarlyBirdTier { id String @id @default(uuid()) eventOptionId String variantId String? deadline DateTime price Float order Int @default(0) stockLimit Int @default(0) eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade) variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade) registrationOptions RegistrationOption[] tranches RegistrationOptionTranche[] @@index([eventOptionId, deadline]) @@index([variantId]) } model OptionVariant { id String @id @default(uuid()) eventOptionId String name String price Float? stockLimit Int @default(0) // Staff price override for this variant (falls back to the option's staffPrice). staffPrice Float? order Int @default(0) eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade) registrationOptions RegistrationOption[] earlyBirdTiers EarlyBirdTier[] couponScopes CouponOption[] waitlistEntries WaitlistEntry[] @@index([eventOptionId]) } model Registration { id String @id @default(uuid()) userId String eventId String status RegistrationStatus @default(pending) checkoutId String? @unique // Sum of this registration's active coupon discounts, kept in step with // couponRedemptions. Stored here so every total-due calculation sees it no matter // which relations the caller loaded (see utils/pricing.js computeRegistrationTotalDue). discountTotal Float @default(0) // Where named attendees' tickets go: all to the payer, or each to the attendee. ticketDelivery TicketDelivery @default(payer) analyticsSessionId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Restrict) event Event @relation(fields: [eventId], references: [id], onDelete: Restrict) registrationOptions RegistrationOption[] payments Payment[] formResponses FormResponse[] formDrafts FormDraft[] couponRedemptions CouponRedemption[] attendees Attendee[] waitlistEntries WaitlistEntry[] @@index([userId]) @@index([eventId]) @@index([userId, status]) @@index([eventId, status]) @@index([createdAt, status]) } model RegistrationOption { id String @id @default(uuid()) registrationId String eventOptionId String quantity Int @default(1) variantId String? appliedTierId String? priceSnapshot Float? registration Registration @relation(fields: [registrationId], references: [id], onDelete: Restrict) eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Restrict) variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull) appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull) tickets Ticket[] tranches RegistrationOptionTranche[] attendees Attendee[] @@index([registrationId]) @@index([eventOptionId]) @@index([variantId]) @@index([appliedTierId]) } // One row per purchase-at-a-price for a RegistrationOption. Never mutated after creation // (mirrors the Payment model's append-only pattern) — this is what lets a single ticket // type be bought in multiple batches at different early-bird prices without either batch's // price bleeding into the other. RegistrationOption.quantity/priceSnapshot/appliedTierId // stay in sync as an aggregate (quantity = sum of tranche quantities; priceSnapshot/appliedTierId // mirror the most recently added tranche) for the many call sites that only need "how many" // or a single display price. model RegistrationOptionTranche { id String @id @default(uuid()) registrationOptionId String quantity Int priceSnapshot Float appliedTierId String? createdAt DateTime @default(now()) registrationOption RegistrationOption @relation(fields: [registrationOptionId], references: [id], onDelete: Cascade) appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull) @@index([registrationOptionId]) @@index([appliedTierId]) } model Payment { id String @id @default(uuid()) amount Float method String userId String recordedById String? registrationId String? eventId String? isDonation Boolean @default(false) // A "sponsor a spot" donation (also isDonation): goes into the event's sponsorship pool. isSponsorship Boolean @default(false) sponsorName String? sponsorMessage String? sponsorAnonymous Boolean @default(false) externalId String? @unique status String? originalPaymentId String? createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Restrict) recordedBy User? @relation("PaymentRecordedBy", fields: [recordedById], references: [id], onDelete: SetNull) registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull) event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull) originalPayment Payment? @relation("SplitPayments", fields: [originalPaymentId], references: [id], onDelete: SetNull) splitPayments Payment[] @relation("SplitPayments") YocoTransaction YocoTransaction[] @@index([userId]) @@index([recordedById]) @@index([registrationId]) @@index([eventId]) @@index([createdAt]) @@index([originalPaymentId, isDonation]) } model Ticket { id String @id @default(uuid()) qrCode String @unique registrationOptionId String userId String eventId String quantity Int @default(1) isUsed Boolean @default(false) emailSent Boolean @default(false) // Set on a named (group) ticket: the person it's for, and their account when it // matched one. userId stays the payer. attendeeId String? @unique attendee Attendee? @relation(fields: [attendeeId], references: [id], onDelete: SetNull) holderUserId String? holder User? @relation("TicketHolder", fields: [holderUserId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt registrationOption RegistrationOption @relation(fields: [registrationOptionId], references: [id], onDelete: Restrict) user User @relation(fields: [userId], references: [id], onDelete: Restrict) event Event @relation(fields: [eventId], references: [id], onDelete: Restrict) usages TicketUsage[] transfers TicketTransfer[] transferRequests TicketTransferRequest[] @@index([holderUserId]) @@index([eventId]) @@index([userId]) @@index([registrationOptionId]) @@index([createdAt]) } model TicketUsage { id String @id @default(uuid()) ticketId String scannedById String scannedAt DateTime @default(now()) quantityRedeemed Int @default(1) // Offline scanning: the device's id for the scan (makes syncing idempotent), and // the offline lock it was made under. clientScanId String? @unique offline Boolean @default(false) scanLockId String? syncedAt DateTime? ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Restrict) scannedBy User @relation(fields: [scannedById], references: [id], onDelete: Restrict) scanLock ScanLock? @relation(fields: [scanLockId], references: [id], onDelete: SetNull) @@index([ticketId]) @@index([scannedAt]) @@index([scannedById, scannedAt]) } model PasswordReset { id String @id @default(uuid()) userId String token String @unique expiresAt DateTime used Boolean @default(false) createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) } // A 6-digit code sent by email/WhatsApp, either as the second step of a login // ('login') or to prove the channel works before 2FA is switched on ('enable'). // Only the HMAC of the code is stored (see backend/src/utils/twoFactor.js). model TwoFactorChallenge { id String @id @default(uuid()) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) purpose String // 'login' | 'enable' method TwoFactorMethod codeHash String attempts Int @default(0) lastSentAt DateTime @default(now()) expiresAt DateTime usedAt DateTime? createdAt DateTime @default(now()) @@index([userId]) } // A browser that passed the 2FA code step with "Trust this device" ticked — logins // presenting its token skip the code until it expires or is cleared. model TrustedDevice { id String @id @default(uuid()) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) tokenHash String @unique device String? createdAt DateTime @default(now()) lastUsedAt DateTime @default(now()) expiresAt DateTime @@index([userId]) } // Append-only "Account activity" log for the Profile & Security page (logins, password // changes). Nullable FK with SetNull (not Cascade) so entries survive account // close/anonymization, the same pattern EventCashup uses for its performedBy audit trail. model SecurityEvent { id String @id @default(uuid()) userId String? user User? @relation(fields: [userId], references: [id], onDelete: SetNull) type SecurityEventType ip String? device String? createdAt DateTime @default(now()) @@index([userId, createdAt]) } enum AdminAuditAction { refund_created donation_assigned donation_unassigned registration_created_manual registration_cancelled event_created event_updated event_deleted settings_updated coupon_created coupon_updated coupon_disabled coupon_applied_manual consent_override consent_rerequested medical_list_viewed scan_lock_acquired scan_lock_released scan_lock_force_released offline_sync ticket_transferred impersonation_started impersonation_ended } // Append-only audit trail for admin/supervisor-initiated actions with money or // data-integrity impact — separate from SecurityEvent (user-account-security-specific, // fixed enum). Nullable FK with SetNull mirrors SecurityEvent's pattern so entries // survive account close/anonymization. model AdminAuditLog { id String @id @default(uuid()) actorId String? actor User? @relation("AdminAuditActor", fields: [actorId], references: [id], onDelete: SetNull) actorRole String action AdminAuditAction targetType String targetId String? metadata Json? ip String? createdAt DateTime @default(now()) @@index([actorId, createdAt]) @@index([action, createdAt]) @@index([targetType, targetId]) } model EventAttachment { id String @id @default(uuid()) eventId String originalName String filename String mimeType String size Int url String createdAt DateTime @default(now()) event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) @@index([eventId]) } // Event registration forms enum FormFieldType { yes_no text date numeric statement paragraph rating multiple_choice } model EventForm { id String @id @default(uuid()) eventId String @unique isRequired Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) fields EventFormField[] } model EventFormField { id String @id @default(uuid()) formId String type FormFieldType label String isRequired Boolean @default(false) order Int @default(0) helpText String? options Json? form EventForm @relation(fields: [formId], references: [id], onDelete: Cascade) answers FormAnswer[] @@index([formId]) } model FormResponse { id String @id @default(uuid()) registrationId String createdAt DateTime @default(now()) registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade) attendeeId String? attendee Attendee? @relation(fields: [attendeeId], references: [id], onDelete: SetNull) answers FormAnswer[] @@index([registrationId]) } model FormAnswer { id String @id @default(uuid()) responseId String fieldId String value String response FormResponse @relation(fields: [responseId], references: [id], onDelete: Cascade) field EventFormField @relation(fields: [fieldId], references: [id], onDelete: Cascade) @@index([responseId]) @@index([fieldId]) } // Draft storage for in-progress attendee forms model FormDraft { id String @id @default(uuid()) registrationId String userId String data Json createdAt DateTime @default(now()) updatedAt DateTime @updatedAt registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([registrationId, userId]) } // Stores all webhook events from Yoco. If a Payment is created (registration or donation), // the record is marked reconciled and linked via paymentId. Otherwise, remains unreconciled. model YocoTransaction { id String @id @default(uuid()) externalId String @unique // Yoco payment/event id amount Int? currency String? createdDate DateTime? checkoutId String? methodType String? // paymentMethodDetails.type (e.g., card) reconciled Boolean @default(false) ignored Boolean @default(false) paymentId String? payment Payment? @relation(fields: [paymentId], references: [id], onDelete: SetNull) raw Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([paymentId]) } model Section { id String @id @default(uuid()) eventId String name String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) allowedOptions SectionOption[] scanLocks ScanLock[] @@index([eventId]) } model SectionOption { id String @id @default(uuid()) sectionId String eventOptionId String section Section @relation(fields: [sectionId], references: [id], onDelete: Cascade) eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade) @@unique([sectionId, eventOptionId]) } model AppSetting { key String @id value String updatedAt DateTime @updatedAt } model EventCost { id String @id @default(uuid()) eventId String event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) eventOptionId String? eventOption EventOption? @relation(fields: [eventOptionId], references: [id], onDelete: Cascade) label String costType EventCostType amount Float paidFromMethod String? // 'cash' | 'card' | 'eft' | 'other' | null — null = not paid from event takings notes String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([eventId]) @@index([eventOptionId]) } // Append-only audit trail: one row per close / quick-close / reopen. Never updated or deleted. model EventCashup { id String @id @default(uuid()) eventId String event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) action CashupAction unallocatedDonationsTotal Float @default(0) totalCosts Float @default(0) totalExpectedRevenue Float @default(0) totalActualRevenue Float? notes String? performedById String? performedBy User? @relation(fields: [performedById], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) lines EventCashupLine[] @@index([eventId, createdAt]) } model EventCashupLine { id String @id @default(uuid()) cashupId String cashup EventCashup @relation(fields: [cashupId], references: [id], onDelete: Cascade) method String expectedAmount Float @default(0) actualAmount Float? variance Float? notes String? denominations EventCashupDenomination[] @@index([cashupId]) } model EventCashupDenomination { id String @id @default(uuid()) lineId String line EventCashupLine @relation(fields: [lineId], references: [id], onDelete: Cascade) value Float count Int @@index([lineId]) } // Actual physical cash counted for one staff member's recorded cash payments, entered any // time (not part of the close flow) purely for accountability — compared against the // system-expected amount (computeCashAccountabilityByUser) to show a per-person variance. model EventCashupPersonCount { id String @id @default(uuid()) eventId String userId String enteredById String? notes String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) user User @relation("EventCashupPersonCountFor", fields: [userId], references: [id], onDelete: Cascade) enteredBy User? @relation("EventCashupPersonCountEnteredBy", fields: [enteredById], references: [id], onDelete: SetNull) denominations EventCashupPersonCountDenomination[] @@unique([eventId, userId]) @@index([eventId]) } model EventCashupPersonCountDenomination { id String @id @default(uuid()) countId String value Float count Int personCount EventCashupPersonCount @relation(fields: [countId], references: [id], onDelete: Cascade) @@index([countId]) } enum CouponType { percent fixed comp } model Coupon { id String @id @default(uuid()) code String @unique // stored uppercase; matched case-insensitively description String? type CouponType value Float @default(0) // percent (0-100) or rand amount; ignored for comp stackEarlyBird Boolean @default(false) // true: discount the early-bird price; false: best price wins validFrom DateTime? validUntil DateTime? maxUses Int? maxUsesPerUser Int? @default(1) minQuantity Int? restrictRoles UserRole[] restrictDomain String? active Boolean @default(true) createdById String? createdBy User? @relation("CouponCreatedBy", fields: [createdById], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt events CouponEvent[] // none = valid for every event options CouponOption[] // none = whole event allowedUsers CouponUser[] redemptions CouponRedemption[] @@index([createdById]) } model CouponEvent { id String @id @default(uuid()) couponId String eventId String coupon Coupon @relation(fields: [couponId], references: [id], onDelete: Cascade) event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) @@unique([couponId, eventId]) @@index([eventId]) } // Limits a coupon to specific ticket options, or to one variant of an option. model CouponOption { id String @id @default(uuid()) couponId String eventOptionId String variantId String? coupon Coupon @relation(fields: [couponId], references: [id], onDelete: Cascade) eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade) variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade) @@index([couponId]) @@index([eventOptionId]) @@index([variantId]) } model CouponUser { id String @id @default(uuid()) couponId String userId String coupon Coupon @relation(fields: [couponId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([couponId, userId]) @@index([userId]) } model CouponRedemption { id String @id @default(uuid()) couponId String registrationId String userId String discountAmount Float // locked at apply time appliedById String? // staff member, when applied on someone's behalf createdAt DateTime @default(now()) releasedAt DateTime? // set when the registration is cancelled — frees the use coupon Coupon @relation(fields: [couponId], references: [id], onDelete: Restrict) registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade) user User @relation("CouponRedemptionUser", fields: [userId], references: [id], onDelete: Restrict) appliedBy User? @relation("CouponRedemptionAppliedBy", fields: [appliedById], references: [id], onDelete: SetNull) @@index([couponId, releasedAt]) @@index([registrationId]) @@index([userId]) @@index([appliedById]) } // ─── Group tickets ──────────────────────────────────────────────────────────── enum TicketDelivery { payer // every ticket goes to the person who registered each // each named attendee gets their own (falls back to the payer) } // One named person on a group registration, for one main-ticket place. Gets their // own quantity-1 ticket and their own attendee form response. /// An admin viewing the site as another user (strictly read-only, at most 30 minutes). model ImpersonationSession { id String @id @default(uuid()) adminId String targetUserId String reason String startedAt DateTime @default(now()) expiresAt DateTime endedAt DateTime? endedReason String? // 'ended' | 'expired' admin User @relation("ImpersonationAdmin", fields: [adminId], references: [id], onDelete: Cascade) target User @relation("ImpersonationTarget", fields: [targetUserId], references: [id], onDelete: Cascade) requests ImpersonationRequestLog[] @@index([adminId]) @@index([targetUserId]) } /// Every page viewed and API call made during an impersonation session. model ImpersonationRequestLog { id String @id @default(uuid()) sessionId String method String path String blocked Boolean @default(false) createdAt DateTime @default(now()) session ImpersonationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) @@index([sessionId, createdAt]) } /// First-party funnel tracking (only after cookie consent): one row per anonymous /// session, event and stage ('view' | 'start' | 'submit'). "Paid" comes from the /// registration that carries the session id. Rows older than 90 days are rolled up /// into AnalyticsDaily and deleted. model AnalyticsEvent { id String @id @default(uuid()) sessionId String eventId String stage String source String? device String? createdAt DateTime @default(now()) event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) @@unique([sessionId, eventId, stage]) @@index([eventId, createdAt]) } /// Rolled-up funnel counts: sessions that reached `stage`, per day, source and device. model AnalyticsDaily { id String @id @default(uuid()) eventId String day DateTime @db.Date stage String source String @default("direct") device String @default("unknown") sessions Int @default(0) event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) @@unique([eventId, day, stage, source, device]) @@index([eventId, day]) } /// Post-event survey. Sent at sendAt to checked-in attendees (or everyone registered), /// with one reminder 3 days later. anonymous responses aren't linked to a person. model FeedbackForm { id String @id @default(uuid()) eventId String @unique sendAt DateTime? audience String @default("checked_in") // 'checked_in' | 'all' anonymous Boolean @default(false) intro String? sentAt DateTime? reminderSentAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) fields FeedbackField[] invites FeedbackInvite[] responses FeedbackResponse[] } model FeedbackField { id String @id @default(uuid()) formId String type FormFieldType label String isRequired Boolean @default(false) order Int @default(0) helpText String? options Json? form FeedbackForm @relation(fields: [formId], references: [id], onDelete: Cascade) answers FeedbackAnswer[] @@index([formId]) } /// Who was asked. tokenHash is the HMAC of the link token (the token itself isn't stored). model FeedbackInvite { id String @id @default(uuid()) formId String userId String tokenHash String @unique sentAt DateTime @default(now()) remindedAt DateTime? respondedAt DateTime? form FeedbackForm @relation(fields: [formId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([formId, userId]) @@index([userId]) } model FeedbackResponse { id String @id @default(uuid()) formId String userId String? // null when the form is anonymous createdAt DateTime @default(now()) form FeedbackForm @relation(fields: [formId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: SetNull) answers FeedbackAnswer[] @@index([formId]) } model FeedbackAnswer { id String @id @default(uuid()) responseId String fieldId String value String response FeedbackResponse @relation(fields: [responseId], references: [id], onDelete: Cascade) field FeedbackField @relation(fields: [fieldId], references: [id], onDelete: Cascade) @@index([responseId]) @@index([fieldId]) } /// A way of splitting an event's attendees up: "Rooms", "Small groups", "Buses". model AllocationSet { id String @id @default(uuid()) eventId String name String showOnTicket Boolean @default(false) order Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) groups AllocationGroup[] assignments AllocationAssignment[] @@index([eventId]) } /// One room / group / bus in a set. gender ('male' | 'female') restricts who can join. model AllocationGroup { id String @id @default(uuid()) setId String name String capacity Int? gender String? leaderUserId String? order Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt set AllocationSet @relation(fields: [setId], references: [id], onDelete: Cascade) leader User? @relation("AllocationGroupLeader", fields: [leaderUserId], references: [id], onDelete: SetNull) assignments AllocationAssignment[] @@index([setId]) @@index([leaderUserId]) } /// An attendee placed in a group. One group per set. model AllocationAssignment { id String @id @default(uuid()) setId String groupId String attendeeId String createdAt DateTime @default(now()) set AllocationSet @relation(fields: [setId], references: [id], onDelete: Cascade) group AllocationGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade) @@unique([setId, attendeeId]) @@index([groupId]) @@index([attendeeId]) } /// A transfer waiting for the new holder to accept it from the link sent to the email / /// phone the sender typed. Nothing changes on the ticket until it's accepted. `details` /// holds what the sender entered (date of birth, gender, parent details on youth events). model TicketTransferRequest { id String @id @default(uuid()) ticketId String requestedById String toName String toEmail String? toPhone String? details Json? tokenHash String @unique status String @default("pending") // pending | accepted | declined | cancelled | expired expiresAt DateTime respondedAt DateTime? createdAt DateTime @default(now()) ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) requestedBy User @relation("TransferRequester", fields: [requestedById], references: [id], onDelete: Cascade) @@index([ticketId, status]) } /// A ticket handed to someone else. oldQrCode is the code that stopped working (null /// when one place was split off a shared group ticket, whose code stays valid). model TicketTransfer { id String @id @default(uuid()) ticketId String fromHolderName String fromUserId String? toName String toUserId String? byUserId String? oldQrCode String? createdAt DateTime @default(now()) ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) toUser User? @relation("TransferRecipient", fields: [toUserId], references: [id], onDelete: SetNull) byUser User? @relation("TransferActor", fields: [byUserId], references: [id], onDelete: SetNull) @@index([ticketId]) @@index([oldQrCode]) } model Attendee { id String @id @default(uuid()) registrationId String registrationOptionId String? eventOptionId String variantId String? name String email String? phone String? userId String? // existing account matched by email/phone dateOfBirth DateTime? gender String? parentName String? parentEmail String? parentPhone String? order Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade) registrationOption RegistrationOption? @relation(fields: [registrationOptionId], references: [id], onDelete: SetNull) user User? @relation(fields: [userId], references: [id], onDelete: SetNull) ticket Ticket? formResponses FormResponse[] consentRequests ConsentRequest[] allocations AllocationAssignment[] @@index([registrationId]) @@index([registrationOptionId]) @@index([userId]) } // ─── Parental consent ───────────────────────────────────────────────────────── enum ConsentStatus { pending given declined } // A request for a parent/guardian to consent for one under-18 attendee. Only the hash // of the link token is stored. A new request (e.g. after the consent text changes) // supersedes earlier ones for the same attendee; the latest one decides check-in. model ConsentRequest { id String @id @default(uuid()) attendeeId String eventId String tokenHash String @unique status ConsentStatus @default(pending) textVersion Int consentText String // the exact text the parent was shown parentName String? parentEmail String? parentPhone String? signedName String? ip String? device String? medical String? // encrypted JSON (utils/encryption.js); purged after the event emergency String? // encrypted JSON; purged after the event photoConsent Boolean? respondedAt DateTime? lastSentAt DateTime @default(now()) remindersSent Int @default(0) expiresAt DateTime medicalPurgedAt DateTime? createdAt DateTime @default(now()) attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade) event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) @@index([attendeeId, createdAt]) @@index([eventId, status]) } // ─── Waitlist ───────────────────────────────────────────────────────────────── enum WaitlistStatus { waiting offered // places held for them until offerExpiresAt claimed // they registered with the offer expired // the offer ran out removed // taken off by staff left // they left the waitlist } model WaitlistEntry { id String @id @default(uuid()) eventId String eventOptionId String variantId String? userId String quantity Int position Int status WaitlistStatus @default(waiting) offeredQuantity Int? offerTokenHash String? @unique offeredAt DateTime? offerExpiresAt DateTime? claimedAt DateTime? registrationId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade) variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull) @@index([eventId, status, position]) @@index([eventOptionId, status]) @@index([variantId]) @@index([userId]) @@index([registrationId]) } // ─── Offline scanning ───────────────────────────────────────────────────────── // Locks a section (or the whole event, sectionId null) to one device for offline // scanning, so two devices can never redeem the same ticket while offline. // One active (releasedAt null) lock per scope — enforced in utils/scanLocks.js. model ScanLock { id String @id @default(uuid()) eventId String sectionId String? deviceId String deviceName String? userId String lockedAt DateTime @default(now()) snapshotAt DateTime? lastSyncAt DateTime? releasedAt DateTime? forceReleasedById String? event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) section Section? @relation(fields: [sectionId], references: [id], onDelete: Cascade) user User @relation("ScanLockOwner", fields: [userId], references: [id], onDelete: Cascade) forceReleasedBy User? @relation("ScanLockForcedBy", fields: [forceReleasedById], references: [id], onDelete: SetNull) usages TicketUsage[] syncReports OfflineSyncReport[] @@index([eventId, releasedAt]) @@index([userId]) @@index([sectionId]) @@index([forceReleasedById]) } // One sync of queued offline scans; `conflicts` lists scans that needed a person to look. model OfflineSyncReport { id String @id @default(uuid()) scanLockId String accepted Int @default(0) duplicates Int @default(0) conflicts Json @default("[]") afterForceRelease Boolean @default(false) createdAt DateTime @default(now()) reviewedAt DateTime? reviewedById String? scanLock ScanLock @relation(fields: [scanLockId], references: [id], onDelete: Cascade) reviewedBy User? @relation("SyncReportReviewer", fields: [reviewedById], references: [id], onDelete: SetNull) @@index([scanLockId]) @@index([reviewedById]) }