- Admin -> Privacy (POPIA): edit the privacy notice (built-in text as the
start, placeholders for org/IO/operators), drafts, preview, numbered
versions; earlier versions readable on /legal/privacy?version=N
- "Significant change" versions are accepted once after login
(NoticePrompt); sign-up records the version accepted
- Operator register (seeded with the services the app uses), listed in
the notice via {operators}
- Consent log (news opt-ins, notice acceptances, parental consent) and
access log (report exports, registers, certificates, group lists, form
answers, consent/medical lists, backups, impersonation)
- Information Officer on the contact page
- Unit tests (privacyNotice.test.js), e2e (privacy-notice.spec.ts); docs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
1734 lines
65 KiB
Plaintext
1734 lines
65 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"
|
|
}
|
|
|
|
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?
|
|
// POPIA: opted in to "News & announcements" (marketing) bulk messages, per channel.
|
|
// Event updates to registrants don't depend on these. History in CommunicationConsent.
|
|
newsEmail Boolean @default(false)
|
|
newsWhatsApp Boolean @default(false)
|
|
// When the person last answered the news opt-in question. Null = ask at next login.
|
|
newsAskedAt DateTime?
|
|
// The privacy notice version they last accepted (0 = the built-in notice). Null = never
|
|
// recorded. Asked again when a version that needs re-acceptance is newer.
|
|
noticeVersionAccepted Int?
|
|
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[]
|
|
pendingResolved EventCashupPendingResolution[] @relation("PendingResolutionBy")
|
|
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[]
|
|
certificates Certificate[]
|
|
certificatesIssued Certificate[] @relation("CertificateIssuedBy")
|
|
communicationConsents CommunicationConsent[]
|
|
dataRequests DataRequest[]
|
|
dataRequestsHandled DataRequest[] @relation("DataRequestHandledBy")
|
|
noticesPublished PrivacyNotice[]
|
|
noticeAcceptances NoticeAcceptance[]
|
|
dataAccessLogs DataAccessLog[]
|
|
}
|
|
|
|
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[]
|
|
bundles EventBundle[]
|
|
|
|
// Multi-day events: `sessions` is one event running on several dates (e.g. every
|
|
// Wednesday for 5 weeks). startDate/endDate are kept in sync with the first session's
|
|
// start and the last session's end (see utils/sessions.js), so code that only reads
|
|
// them keeps working.
|
|
scheduleType ScheduleType @default(single)
|
|
attendanceMode AttendanceMode @default(whole)
|
|
// Late joining: registration stays open until session `lateJoinUntilSessionId` starts
|
|
// (otherwise it closes when the first session starts). Late pricing applies to main
|
|
// tickets only.
|
|
lateJoinEnabled Boolean @default(false)
|
|
lateJoinUntilSessionId String?
|
|
latePricing LatePricing @default(full)
|
|
lateFixedPrice Float?
|
|
checkInWindow CheckInWindow @default(whole_day)
|
|
// null = off; { offsets: [minutes before start], channels: ["email", "whatsapp"] }
|
|
sessionReminders Json?
|
|
certificatesEnabled Boolean @default(false)
|
|
// { template, title, body, signatureName, signatureRole, signatureImage, rule: { type, value } }
|
|
certificateConfig Json?
|
|
sessions EventSession[]
|
|
certificates Certificate[]
|
|
|
|
@@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?
|
|
// Multi-day events in "pick sessions" mode: `pick` options are bought per session
|
|
// (price is per session, stockLimit is places per session); `all` options cover every session.
|
|
sessionMode OptionSessionMode @default(all)
|
|
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
|
|
registrationOptions RegistrationOption[]
|
|
earlyBirdTiers EarlyBirdTier[]
|
|
SectionOption SectionOption[]
|
|
variants OptionVariant[]
|
|
costs EventCost[]
|
|
couponScopes CouponOption[]
|
|
waitlistEntries WaitlistEntry[]
|
|
bundleItems EventBundleItem[]
|
|
|
|
@@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[]
|
|
bundleItems EventBundleItem[]
|
|
|
|
@@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 discounts: bundle deals plus active coupon discounts,
|
|
// kept in step with bundleApplications and 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)
|
|
// The bundle-deal part of discountTotal (the rest is coupons).
|
|
bundleDiscount 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[]
|
|
bundleApplications RegistrationBundle[]
|
|
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[]
|
|
// A "pick sessions" option: one line (and ticket) per session booked. Null for
|
|
// everything else.
|
|
sessionId String?
|
|
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Restrict)
|
|
// An all-sessions option on a "pick sessions" event: the sessions chosen (one line,
|
|
// one ticket, one price). Empty = every session.
|
|
sessionIds String[] @default([])
|
|
|
|
@@index([registrationId])
|
|
@@index([eventOptionId])
|
|
@@index([variantId])
|
|
@@index([appliedTierId])
|
|
@@index([sessionId])
|
|
}
|
|
|
|
// 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?
|
|
// Online payments: the payment provider that took it (src/payments, e.g. "yoco"). Null for
|
|
// payments recorded by staff. Payment.method stays the wallet type (card, apple_pay, …).
|
|
provider 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")
|
|
providerTransactions ProviderTransaction[]
|
|
|
|
@@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[]
|
|
certificates Certificate[]
|
|
|
|
@@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?
|
|
// Multi-day events: the session this check-in counts for (null on single-date events).
|
|
sessionId String?
|
|
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Restrict)
|
|
// Ticked present by a supervisor from the attendance register, not scanned.
|
|
manual Boolean @default(false)
|
|
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])
|
|
@@index([sessionId])
|
|
}
|
|
|
|
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
|
|
bundle_created
|
|
bundle_updated
|
|
bundle_deleted
|
|
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
|
|
yoco_config_updated
|
|
yoco_webhook_registered
|
|
yoco_webhook_deleted
|
|
payment_providers_updated
|
|
payfast_config_updated
|
|
event_sessions_changed
|
|
attendance_marked
|
|
attendance_unmarked
|
|
certificate_issued
|
|
certificate_revoked
|
|
job_run_manual
|
|
data_request_completed
|
|
data_request_rejected
|
|
user_anonymised
|
|
privacy_notice_published
|
|
operator_created
|
|
operator_updated
|
|
operator_deleted
|
|
}
|
|
|
|
// 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.
|
|
// A transaction a payment provider told us about (by webhook). Matched to a Payment when it
|
|
// belongs to one of our checkouts; the rest wait on the Payments page to be reconciled.
|
|
model ProviderTransaction {
|
|
id String @id @default(uuid())
|
|
provider String @default("yoco") // payment provider id (src/payments)
|
|
externalId String // the provider's 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?
|
|
// Why it wasn't recorded automatically and needs a look (e.g. paid a different amount
|
|
// from the checkout).
|
|
note String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@unique([provider, externalId])
|
|
@@index([paymentId])
|
|
}
|
|
|
|
// An online checkout we started: what it's for and how much, so the payment that comes back
|
|
// can be checked against it. Its id is the payer's link (/pay/<id>) and, for PayFast, the
|
|
// m_payment_id. `provider` is null until the payer picks one (when several are enabled).
|
|
model ProviderCheckout {
|
|
id String @id @default(uuid())
|
|
provider String?
|
|
// The provider's own checkout id (Yoco) and where it sends the payer.
|
|
externalId String?
|
|
redirectUrl String?
|
|
amountCents Int
|
|
currency String @default("ZAR")
|
|
// Who/what the payment is for: registrationId, userId, eventId, type, donationAmount, …
|
|
metadata Json
|
|
// Everything needed to start the checkout with any provider: lineItems, return URLs, payer.
|
|
request Json
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([provider, externalId])
|
|
}
|
|
|
|
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])
|
|
}
|
|
|
|
// Where one method's expected takings ended up. actualAmount = bank + on hand + pending
|
|
// (money held or on its way); write-offs explain money that's gone (fees, charges,
|
|
// shortfalls). expected - actual - writeOffTotal is what's still unaccounted for.
|
|
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?
|
|
bankAmount Float?
|
|
onHandAmount Float?
|
|
pendingAmount Float?
|
|
// Card/Yoco report or POP reference for the pending amount.
|
|
pendingReference String?
|
|
writeOffTotal Float @default(0)
|
|
denominations EventCashupDenomination[]
|
|
writeOffs EventCashupWriteOff[]
|
|
pendingResolutions EventCashupPendingResolution[]
|
|
|
|
@@index([cashupId])
|
|
}
|
|
|
|
enum CashupWriteOffCategory {
|
|
card_fees
|
|
bank_charges
|
|
cash_shortfall
|
|
other
|
|
}
|
|
|
|
model EventCashupWriteOff {
|
|
id String @id @default(uuid())
|
|
lineId String
|
|
line EventCashupLine @relation(fields: [lineId], references: [id], onDelete: Cascade)
|
|
category CashupWriteOffCategory
|
|
amount Float
|
|
reason String?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([lineId])
|
|
}
|
|
|
|
// Pending money settled after the event was closed: it reached the bank ('received') or
|
|
// never will ('written_off'). Append-only, like EventCashup — the line itself is never edited.
|
|
model EventCashupPendingResolution {
|
|
id String @id @default(uuid())
|
|
lineId String
|
|
line EventCashupLine @relation(fields: [lineId], references: [id], onDelete: Cascade)
|
|
outcome String // 'received' | 'written_off'
|
|
amount Float
|
|
category CashupWriteOffCategory?
|
|
notes String?
|
|
performedById String?
|
|
performedBy User? @relation("PendingResolutionBy", fields: [performedById], references: [id], onDelete: SetNull)
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([lineId])
|
|
@@index([performedById])
|
|
}
|
|
|
|
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])
|
|
}
|
|
|
|
// ─── Bundle deals ─────────────────────────────────────────────────────────────
|
|
|
|
// How a bundle is made up (see utils/bundles.js):
|
|
// exact: these tickets in these numbers, for `price`, e.g. 5 Adult + 3 Child for R500
|
|
// mix: any `setSize` of the qualifying tickets, for `price`
|
|
// free: any `setSize` of the qualifying tickets, the cheapest `freeCount` free
|
|
// group: `setSize` or more qualifying tickets, all at `price` each (or `price`% off)
|
|
enum BundleKind {
|
|
exact
|
|
mix
|
|
free
|
|
group
|
|
}
|
|
|
|
enum BundleGroupPricing {
|
|
per_ticket
|
|
percent
|
|
}
|
|
|
|
// Tickets sold together at a better price. Applied automatically, as many times as a
|
|
// registration's items allow (a group discount once), and only when it beats what the
|
|
// items would cost anyway (see utils/bundles.js).
|
|
model EventBundle {
|
|
id String @id @default(uuid())
|
|
eventId String
|
|
name String
|
|
description String?
|
|
kind BundleKind @default(exact)
|
|
// exact/mix: the set's price. group: the per-ticket price, or the percentage off.
|
|
price Float
|
|
setSize Int?
|
|
freeCount Int?
|
|
groupPricing BundleGroupPricing?
|
|
isActive Boolean @default(true)
|
|
validFrom DateTime?
|
|
validUntil DateTime?
|
|
order Int @default(0)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
|
items EventBundleItem[]
|
|
uses RegistrationBundle[]
|
|
|
|
@@index([eventId])
|
|
}
|
|
|
|
// One line of a bundle: for exact bundles a ticket and how many; for the others a
|
|
// qualifying ticket (quantity unused). variantId null = any variant of the option.
|
|
model EventBundleItem {
|
|
id String @id @default(uuid())
|
|
bundleId String
|
|
eventOptionId String
|
|
variantId String?
|
|
quantity Int
|
|
bundle EventBundle @relation(fields: [bundleId], references: [id], onDelete: Cascade)
|
|
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
|
|
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([bundleId])
|
|
@@index([eventOptionId])
|
|
@@index([variantId])
|
|
}
|
|
|
|
// How many times a bundle applies to a registration and what it saved. Rebuilt each
|
|
// time the registration's discounts are recalculated.
|
|
model RegistrationBundle {
|
|
id String @id @default(uuid())
|
|
registrationId String
|
|
bundleId String
|
|
count Int
|
|
discount Float
|
|
createdAt DateTime @default(now())
|
|
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
|
|
bundle EventBundle @relation(fields: [bundleId], references: [id], onDelete: Restrict)
|
|
|
|
@@index([registrationId])
|
|
@@index([bundleId])
|
|
}
|
|
|
|
// ─── 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?
|
|
// "Pick sessions" options: the session they're waiting for. Null for everything else.
|
|
sessionId 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)
|
|
session EventSession? @relation(fields: [sessionId], 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([sessionId])
|
|
@@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])
|
|
}
|
|
|
|
enum ScheduleType {
|
|
single
|
|
sessions
|
|
}
|
|
|
|
enum AttendanceMode {
|
|
whole
|
|
pick
|
|
}
|
|
|
|
enum LatePricing {
|
|
full
|
|
prorata
|
|
fixed
|
|
}
|
|
|
|
enum OptionSessionMode {
|
|
all
|
|
pick
|
|
}
|
|
|
|
enum CheckInWindow {
|
|
whole_day
|
|
session_only
|
|
}
|
|
|
|
// One date/time of a multi-day event.
|
|
model EventSession {
|
|
id String @id @default(uuid())
|
|
eventId String
|
|
label String?
|
|
startsAt DateTime
|
|
endsAt DateTime
|
|
location String?
|
|
// Cancelled sessions are kept (they may have check-ins) but hidden from the schedule
|
|
// and left out of attendance totals.
|
|
cancelledAt DateTime?
|
|
order Int @default(0)
|
|
// Optional limit on people at this session (picked places + whole-course tickets).
|
|
capacity Int?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
|
usages TicketUsage[]
|
|
reminders SessionReminderLog[]
|
|
registrationOptions RegistrationOption[]
|
|
waitlistEntries WaitlistEntry[]
|
|
|
|
@@index([eventId, startsAt])
|
|
}
|
|
|
|
// Which session reminders have gone out, so the job sends each one once.
|
|
model SessionReminderLog {
|
|
id String @id @default(uuid())
|
|
sessionId String
|
|
// Minutes before the session start this reminder is for.
|
|
offset Int
|
|
sent Int @default(0)
|
|
sentAt DateTime @default(now())
|
|
session EventSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([sessionId, offset])
|
|
}
|
|
|
|
// A completion certificate for one ticket (one person).
|
|
model Certificate {
|
|
id String @id @default(uuid())
|
|
eventId String
|
|
ticketId String
|
|
userId String?
|
|
name String
|
|
// Short code printed on the certificate and checked on the public verify page.
|
|
code String @unique
|
|
sessionsAttended Int @default(0)
|
|
totalSessions Int @default(0)
|
|
manual Boolean @default(false)
|
|
issuedById String?
|
|
issuedAt DateTime @default(now())
|
|
emailedAt DateTime?
|
|
revokedAt DateTime?
|
|
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
|
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
|
issuedBy User? @relation("CertificateIssuedBy", fields: [issuedById], references: [id], onDelete: SetNull)
|
|
|
|
@@unique([eventId, ticketId])
|
|
@@index([userId])
|
|
@@index([ticketId])
|
|
@@index([issuedById])
|
|
}
|
|
|
|
// ─── POPIA ────────────────────────────────────────────────────────────────────
|
|
|
|
// Every change to someone's "News & announcements" opt-in: who, which channel, on or off,
|
|
// when and how. Append-only — it's the proof of consent. `address` is set instead of
|
|
// userId for people without an account (an ad-hoc broadcast address that unsubscribed).
|
|
model CommunicationConsent {
|
|
id String @id @default(uuid())
|
|
userId String?
|
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
|
address String?
|
|
channel String // email | whatsapp
|
|
granted Boolean
|
|
// signup | privacy_centre | prompt | unsubscribe_link | whatsapp_stop | existing_account | admin
|
|
source String
|
|
ip String?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([userId, createdAt])
|
|
@@index([address])
|
|
}
|
|
|
|
// Addresses (email, or phone as 27…) that unsubscribed from news without an account,
|
|
// e.g. ad-hoc broadcast recipients. News broadcasts skip them.
|
|
model MessagingOptOut {
|
|
id String @id @default(uuid())
|
|
channel String // email | whatsapp
|
|
address String
|
|
source String
|
|
createdAt DateTime @default(now())
|
|
|
|
@@unique([channel, address])
|
|
}
|
|
|
|
enum DataRequestType {
|
|
access
|
|
correction
|
|
deletion
|
|
}
|
|
|
|
enum DataRequestStatus {
|
|
open
|
|
completed
|
|
rejected
|
|
cancelled
|
|
}
|
|
|
|
// A POPIA request from a data subject. Name and email are copied in so the request
|
|
// still shows who made it after the account is anonymised.
|
|
model DataRequest {
|
|
id String @id @default(uuid())
|
|
type DataRequestType
|
|
status DataRequestStatus @default(open)
|
|
userId String?
|
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
|
name String
|
|
email String
|
|
details String?
|
|
// The admin's answer, sent to the requester when the request is closed.
|
|
response String?
|
|
// Made and fulfilled by the person themselves (e.g. "Download my data").
|
|
selfService Boolean @default(false)
|
|
dueAt DateTime
|
|
closedAt DateTime?
|
|
handledById String?
|
|
handledBy User? @relation("DataRequestHandledBy", fields: [handledById], references: [id], onDelete: SetNull)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([status, dueAt])
|
|
@@index([userId])
|
|
}
|
|
|
|
// A published version of the privacy notice (versions start at 1). With none published,
|
|
// the built-in notice from utils/privacyNotice.js is shown as version 0. Section bodies
|
|
// keep their {placeholders} (org name, Information Officer, operators…), filled in when
|
|
// shown, so the notice follows Site settings and the operator register.
|
|
model PrivacyNotice {
|
|
id String @id @default(uuid())
|
|
version Int @unique
|
|
sections Json // [{ id, title, body }]
|
|
changeSummary String?
|
|
// Everyone is asked to accept this version again after logging in.
|
|
requiresAcceptance Boolean @default(false)
|
|
publishedAt DateTime @default(now())
|
|
publishedById String?
|
|
publishedBy User? @relation(fields: [publishedById], references: [id], onDelete: SetNull)
|
|
}
|
|
|
|
// Someone accepted a version of the privacy notice. Append-only proof, like
|
|
// CommunicationConsent.
|
|
model NoticeAcceptance {
|
|
id String @id @default(uuid())
|
|
userId String?
|
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
|
version Int
|
|
source String // signup | prompt
|
|
ip String?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([userId, createdAt])
|
|
@@index([version])
|
|
}
|
|
|
|
// A third party that processes personal information for the church (POPIA "operator"):
|
|
// payment providers, WhatsApp and email delivery, hosting… Active ones are listed in the
|
|
// privacy notice.
|
|
model Operator {
|
|
id String @id @default(uuid())
|
|
name String
|
|
purpose String
|
|
dataShared String
|
|
location String?
|
|
website String?
|
|
active Boolean @default(true)
|
|
order Int @default(0)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
|
|
// Someone exported or viewed personal information in bulk: report exports, the attendance
|
|
// register, certificate downloads, group lists, form answers, the consent and medical lists,
|
|
// backups, and viewing the site as another user.
|
|
model DataAccessLog {
|
|
id String @id @default(uuid())
|
|
actorId String?
|
|
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
|
|
actorRole String
|
|
// report_pdf | report_excel | report_email | report_whatsapp | attendance_export |
|
|
// certificates_download | group_lists | form_responses | consent_list | medical_list |
|
|
// backup_download | impersonation
|
|
action String
|
|
what String
|
|
eventId String?
|
|
records Int?
|
|
ip String?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([createdAt])
|
|
@@index([actorId, createdAt])
|
|
@@index([action, createdAt])
|
|
}
|