Files
hope-events/backend/prisma/schema.prisma
T
joshua b75be18a87 Add event/organisation location with Google Maps links
Events and the organisation profile now have an address, with a
"Directions" link and an embedded Google Maps view (no API key
required) shown on event pages, event cards, and the Contact page.
New events default their location to the org's configured address.
2026-08-21 11:47:23 +02:00

729 lines
25 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
}
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)
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[]
}
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)
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")
@@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)
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
registrationOptions RegistrationOption[]
earlyBirdTiers EarlyBirdTier[]
SectionOption SectionOption[]
variants OptionVariant[]
costs EventCost[]
@@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)
order Int @default(0)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
registrationOptions RegistrationOption[]
earlyBirdTiers EarlyBirdTier[]
@@index([eventOptionId])
}
model Registration {
id String @id @default(uuid())
userId String
eventId String
status RegistrationStatus @default(pending)
checkoutId String? @unique
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[]
@@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[]
@@index([registrationId])
@@index([eventOptionId])
@@index([variantId])
@@index([appliedTierId])
}
// One row per purchase-at-a-price for a RegistrationOption. Never mutated after creation
// (mirrors the Payment model's append-only pattern) — this is what lets a single ticket
// type be bought in multiple batches at different early-bird prices without either batch's
// price bleeding into the other. RegistrationOption.quantity/priceSnapshot/appliedTierId
// stay in sync as an aggregate (quantity = sum of tranche quantities; priceSnapshot/appliedTierId
// mirror the most recently added tranche) for the many call sites that only need "how many"
// or a single display price.
model RegistrationOptionTranche {
id String @id @default(uuid())
registrationOptionId String
quantity Int
priceSnapshot Float
appliedTierId String?
createdAt DateTime @default(now())
registrationOption RegistrationOption @relation(fields: [registrationOptionId], references: [id], onDelete: Cascade)
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
@@index([registrationOptionId])
@@index([appliedTierId])
}
model Payment {
id String @id @default(uuid())
amount Float
method String
userId String
recordedById String?
registrationId String?
eventId String?
isDonation Boolean @default(false)
externalId String? @unique
status String?
originalPaymentId String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
recordedBy User? @relation("PaymentRecordedBy", fields: [recordedById], references: [id], onDelete: SetNull)
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
originalPayment Payment? @relation("SplitPayments", fields: [originalPaymentId], references: [id], onDelete: SetNull)
splitPayments Payment[] @relation("SplitPayments")
YocoTransaction YocoTransaction[]
@@index([userId])
@@index([recordedById])
@@index([registrationId])
@@index([eventId])
@@index([createdAt])
@@index([originalPaymentId, isDonation])
}
model Ticket {
id String @id @default(uuid())
qrCode String @unique
registrationOptionId String
userId String
eventId String
quantity Int @default(1)
isUsed Boolean @default(false)
emailSent Boolean @default(false)
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[]
@@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)
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Restrict)
scannedBy User @relation(fields: [scannedById], references: [id], onDelete: Restrict)
@@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])
}
// 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])
}
model EventAttachment {
id String @id @default(uuid())
eventId String
originalName String
filename String
mimeType String
size Int
url String
createdAt DateTime @default(now())
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
@@index([eventId])
}
// Event registration forms
enum FormFieldType {
yes_no
text
date
numeric
statement
paragraph
}
model EventForm {
id String @id @default(uuid())
eventId String @unique
isRequired Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
fields EventFormField[]
}
model EventFormField {
id String @id @default(uuid())
formId String
type FormFieldType
label String
isRequired Boolean @default(false)
order Int @default(0)
helpText String?
options Json?
form EventForm @relation(fields: [formId], references: [id], onDelete: Cascade)
answers FormAnswer[]
@@index([formId])
}
model FormResponse {
id String @id @default(uuid())
registrationId String
createdAt DateTime @default(now())
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
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[]
@@index([eventId])
}
model SectionOption {
id String @id @default(uuid())
sectionId String
eventOptionId String
section Section @relation(fields: [sectionId], references: [id], onDelete: Cascade)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
@@unique([sectionId, eventOptionId])
}
model AppSetting {
key String @id
value String
updatedAt DateTime @updatedAt
}
model EventCost {
id String @id @default(uuid())
eventId String
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
eventOptionId String?
eventOption EventOption? @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
label String
costType EventCostType
amount Float
paidFromMethod String? // 'cash' | 'card' | 'eft' | 'other' | null — null = not paid from event takings
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([eventId])
@@index([eventOptionId])
}
// Append-only audit trail: one row per close / quick-close / reopen. Never updated or deleted.
model EventCashup {
id String @id @default(uuid())
eventId String
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
action CashupAction
unallocatedDonationsTotal Float @default(0)
totalCosts Float @default(0)
totalExpectedRevenue Float @default(0)
totalActualRevenue Float?
notes String?
performedById String?
performedBy User? @relation(fields: [performedById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
lines EventCashupLine[]
@@index([eventId, createdAt])
}
model EventCashupLine {
id String @id @default(uuid())
cashupId String
cashup EventCashup @relation(fields: [cashupId], references: [id], onDelete: Cascade)
method String
expectedAmount Float @default(0)
actualAmount Float?
variance Float?
notes String?
denominations EventCashupDenomination[]
@@index([cashupId])
}
model EventCashupDenomination {
id String @id @default(uuid())
lineId String
line EventCashupLine @relation(fields: [lineId], references: [id], onDelete: Cascade)
value Float
count Int
@@index([lineId])
}
// Actual physical cash counted for one staff member's recorded cash payments, entered any
// time (not part of the close flow) purely for accountability — compared against the
// system-expected amount (computeCashAccountabilityByUser) to show a per-person variance.
model EventCashupPersonCount {
id String @id @default(uuid())
eventId String
userId String
enteredById String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
user User @relation("EventCashupPersonCountFor", fields: [userId], references: [id], onDelete: Cascade)
enteredBy User? @relation("EventCashupPersonCountEnteredBy", fields: [enteredById], references: [id], onDelete: SetNull)
denominations EventCashupPersonCountDenomination[]
@@unique([eventId, userId])
@@index([eventId])
}
model EventCashupPersonCountDenomination {
id String @id @default(uuid())
countId String
value Float
count Int
personCount EventCashupPersonCount @relation(fields: [countId], references: [id], onDelete: Cascade)
@@index([countId])
}
// ─── Church Website Models (disabled — kept for reference, not active Prisma models) ──
//
// model Ministry {
// id String @id @default(uuid())
// slug String @unique
// title String
// description String?
// image String?
// isActive Boolean @default(true)
// order Int @default(0)
// leaderId String?
// leader TeamMember? @relation(fields: [leaderId], references: [id], onDelete: SetNull)
// interests MinistryInterest[]
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
//
// @@index([isActive, order])
// @@index([leaderId])
// }
//
// model TeamMember {
// id String @id @default(uuid())
// name String
// title String
// bio String?
// photo String?
// order Int @default(0)
// isActive Boolean @default(true)
// email String?
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
// ministries Ministry[]
//
// @@index([isActive, order])
// }
//
// model ContentBlock {
// id String @id @default(uuid())
// page String
// key String
// value String
// type String @default("text")
// updatedAt DateTime @updatedAt
//
// @@unique([page, key])
// @@index([page])
// }
//
// model ChurchDocument {
// id String @id @default(uuid())
// title String
// description String?
// fileUrl String
// category String @default("other")
// isPublic Boolean @default(true)
// uploadedAt DateTime @default(now())
// updatedAt DateTime @updatedAt
// }
//
// model FellowshipGroup {
// id String @id @default(uuid())
// name String
// description String?
// leader String?
// isActive Boolean @default(true)
// order Int @default(0)
// members ChurchMember[]
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
//
// @@index([isActive, order])
// }
//
// model ChurchMember {
// id String @id @default(uuid())
// status String @default("pending")
// mainName String
// mainEmail String
// mainPhone String?
// mainBirthday DateTime?
// mainOccupation String?
// mainEmploymentStatus String?
// mainDateOfSalvation DateTime?
// mainDateOfWaterBaptism DateTime?
// mainHolySpiritBaptism Boolean @default(false)
// spouseName String?
// spouseEmail String?
// spousePhone String?
// spouseBirthday DateTime?
// spouseOccupation String?
// spouseEmploymentStatus String?
// spouseDateOfSalvation DateTime?
// spouseDateOfWaterBaptism DateTime?
// spouseHolySpiritBaptism Boolean?
// homeAddress String?
// inFellowshipGroup Boolean @default(false)
// fellowshipGroupId String?
// fellowshipGroup FellowshipGroup? @relation(fields: [fellowshipGroupId], references: [id], onDelete: SetNull)
// membershipConfirmed Boolean @default(false)
// notes String?
// appliedAt DateTime @default(now())
// approvedAt DateTime?
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
// children ChurchChild[]
// ministryInterests MinistryInterest[]
// donations Donation[]
//
// @@index([status])
// @@index([mainEmail])
// @@index([fellowshipGroupId])
// }
//
// model ChurchChild {
// id String @id @default(uuid())
// memberId String
// name String
// birthday DateTime?
// member ChurchMember @relation(fields: [memberId], references: [id], onDelete: Cascade)
//
// @@index([memberId])
// }
//
// model MinistryInterest {
// id String @id @default(uuid())
// memberId String
// ministryId String
// forSpouse Boolean @default(false)
// member ChurchMember @relation(fields: [memberId], references: [id], onDelete: Cascade)
// ministry Ministry @relation(fields: [ministryId], references: [id], onDelete: Cascade)
//
// @@unique([memberId, ministryId, forSpouse])
// @@index([memberId])
// @@index([ministryId])
// }
//
// model Donation {
// id String @id @default(uuid())
// donorName String?
// donorEmail String?
// donorPhone String?
// amount Float
// method String
// reference String?
// note String?
// isAnonymous Boolean @default(false)
// linkedMemberId String?
// linkedMember ChurchMember? @relation(fields: [linkedMemberId], references: [id], onDelete: SetNull)
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
//
// @@index([linkedMemberId])
// @@index([createdAt])
// @@index([method])
// }