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