Payers and holders can hand a ticket to someone else before the event. The new holder gets a new QR code (and an inactive account with an activation link if they're new); the old code stops working and scans of it, online or in offline sync, say it was transferred. One place can be split off a shared group ticket. Transfers can be turned off per event (supervisors can always transfer from At the door), youth events need the new holder's own consent, and every transfer is audited. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1177 lines
43 KiB
Plaintext
1177 lines
43 KiB
Plaintext
// 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")
|
|
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)
|
|
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)
|
|
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)
|
|
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[]
|
|
|
|
@@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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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])
|
|
}
|
|
|
|
// ─── Church Website Models (disabled — kept for reference, not active Prisma models) ──
|
|
//
|
|
// model Ministry {
|
|
// id String @id @default(uuid())
|
|
// slug String @unique
|
|
// title String
|
|
// description String?
|
|
// image String?
|
|
// isActive Boolean @default(true)
|
|
// order Int @default(0)
|
|
// leaderId String?
|
|
// leader TeamMember? @relation(fields: [leaderId], references: [id], onDelete: SetNull)
|
|
// interests MinistryInterest[]
|
|
// createdAt DateTime @default(now())
|
|
// updatedAt DateTime @updatedAt
|
|
//
|
|
// @@index([isActive, order])
|
|
// @@index([leaderId])
|
|
// }
|
|
//
|
|
// model TeamMember {
|
|
// id String @id @default(uuid())
|
|
// name String
|
|
// title String
|
|
// bio String?
|
|
// photo String?
|
|
// order Int @default(0)
|
|
// isActive Boolean @default(true)
|
|
// email String?
|
|
// createdAt DateTime @default(now())
|
|
// updatedAt DateTime @updatedAt
|
|
// ministries Ministry[]
|
|
//
|
|
// @@index([isActive, order])
|
|
// }
|
|
//
|
|
// model ContentBlock {
|
|
// id String @id @default(uuid())
|
|
// page String
|
|
// key String
|
|
// value String
|
|
// type String @default("text")
|
|
// updatedAt DateTime @updatedAt
|
|
//
|
|
// @@unique([page, key])
|
|
// @@index([page])
|
|
// }
|
|
//
|
|
// model ChurchDocument {
|
|
// id String @id @default(uuid())
|
|
// title String
|
|
// description String?
|
|
// fileUrl String
|
|
// category String @default("other")
|
|
// isPublic Boolean @default(true)
|
|
// uploadedAt DateTime @default(now())
|
|
// updatedAt DateTime @updatedAt
|
|
// }
|
|
//
|
|
// model FellowshipGroup {
|
|
// id String @id @default(uuid())
|
|
// name String
|
|
// description String?
|
|
// leader String?
|
|
// isActive Boolean @default(true)
|
|
// order Int @default(0)
|
|
// members ChurchMember[]
|
|
// createdAt DateTime @default(now())
|
|
// updatedAt DateTime @updatedAt
|
|
//
|
|
// @@index([isActive, order])
|
|
// }
|
|
//
|
|
// model ChurchMember {
|
|
// id String @id @default(uuid())
|
|
// status String @default("pending")
|
|
// mainName String
|
|
// mainEmail String
|
|
// mainPhone String?
|
|
// mainBirthday DateTime?
|
|
// mainOccupation String?
|
|
// mainEmploymentStatus String?
|
|
// mainDateOfSalvation DateTime?
|
|
// mainDateOfWaterBaptism DateTime?
|
|
// mainHolySpiritBaptism Boolean @default(false)
|
|
// spouseName String?
|
|
// spouseEmail String?
|
|
// spousePhone String?
|
|
// spouseBirthday DateTime?
|
|
// spouseOccupation String?
|
|
// spouseEmploymentStatus String?
|
|
// spouseDateOfSalvation DateTime?
|
|
// spouseDateOfWaterBaptism DateTime?
|
|
// spouseHolySpiritBaptism Boolean?
|
|
// homeAddress String?
|
|
// inFellowshipGroup Boolean @default(false)
|
|
// fellowshipGroupId String?
|
|
// fellowshipGroup FellowshipGroup? @relation(fields: [fellowshipGroupId], references: [id], onDelete: SetNull)
|
|
// membershipConfirmed Boolean @default(false)
|
|
// notes String?
|
|
// appliedAt DateTime @default(now())
|
|
// approvedAt DateTime?
|
|
// createdAt DateTime @default(now())
|
|
// updatedAt DateTime @updatedAt
|
|
// children ChurchChild[]
|
|
// ministryInterests MinistryInterest[]
|
|
// donations Donation[]
|
|
//
|
|
// @@index([status])
|
|
// @@index([mainEmail])
|
|
// @@index([fellowshipGroupId])
|
|
// }
|
|
//
|
|
// model ChurchChild {
|
|
// id String @id @default(uuid())
|
|
// memberId String
|
|
// name String
|
|
// birthday DateTime?
|
|
// member ChurchMember @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
|
//
|
|
// @@index([memberId])
|
|
// }
|
|
//
|
|
// model MinistryInterest {
|
|
// id String @id @default(uuid())
|
|
// memberId String
|
|
// ministryId String
|
|
// forSpouse Boolean @default(false)
|
|
// member ChurchMember @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
|
// ministry Ministry @relation(fields: [ministryId], references: [id], onDelete: Cascade)
|
|
//
|
|
// @@unique([memberId, ministryId, forSpouse])
|
|
// @@index([memberId])
|
|
// @@index([ministryId])
|
|
// }
|
|
//
|
|
// model Donation {
|
|
// id String @id @default(uuid())
|
|
// donorName String?
|
|
// donorEmail String?
|
|
// donorPhone String?
|
|
// amount Float
|
|
// method String
|
|
// reference String?
|
|
// note String?
|
|
// isAnonymous Boolean @default(false)
|
|
// linkedMemberId String?
|
|
// linkedMember ChurchMember? @relation(fields: [linkedMemberId], references: [id], onDelete: SetNull)
|
|
// createdAt DateTime @default(now())
|
|
// updatedAt DateTime @updatedAt
|
|
//
|
|
// @@index([linkedMemberId])
|
|
// @@index([createdAt])
|
|
// @@index([method])
|
|
// }
|
|
|
|
// ─── Coupon codes ─────────────────────────────────────────────────────────────
|
|
// A code gives a percentage, fixed-amount or 100% (comp) discount on a registration.
|
|
// The discount is locked into a CouponRedemption when applied, like early-bird
|
|
// tranches: later pricing refreshes never re-price it (see utils/coupons.js).
|
|
|
|
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.
|
|
/// 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[]
|
|
|
|
@@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])
|
|
}
|