changemaker.lite/api/prisma/schema.prisma
bunker-admin b215cda018 Security audit follow-up: httpOnly cookies, ticket reservations, MongoDB keyfile
Deferred findings from the March 27 security audit, plus a bug fix:

MongoDB keyfile (bug fix):
- Generate replica.key on first boot via entrypoint script
- Fixes crash from --auth + --keyFile without an existing keyfile
- Applied to docker-compose.yml, docker-compose.prod.yml, CCP template

I7 — Ticket overselling prevention (reservation pattern):
- Add reservedCount field to TicketTier schema
- Atomically increment reservedCount inside transaction on checkout
- Release reservation on checkout.session.completed (webhook)
- Release reservation on checkout.session.expired (webhook)
- Include reservedCount in availability calculations

I17 — Move refresh token to httpOnly cookie:
- Server sets httpOnly SameSite=Strict cookie on login/register/refresh
- Cookie scoped to /api/auth path, secure in production
- Refresh/logout endpoints read from cookie (with body fallback for compat)
- Frontend no longer stores refreshToken in localStorage
- Auth store simplified: removed refreshToken from state + persistence
- API interceptor uses withCredentials:true for automatic cookie sending
- Updated media-api, media-public-api, QuickJoinPage, volunteer-invite
- Renamed getTokens → getAccessToken across all media components
- Install cookie-parser middleware

L2 — FeatureGate loading state:
- Show Skeleton instead of children while settings are loading
- Prevents briefly exposing disabled feature pages

Bunker Admin
2026-03-27 09:20:26 -06:00

5287 lines
195 KiB
Plaintext

generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ============================================================================
// AUTH & USERS
// ============================================================================
enum UserRole {
SUPER_ADMIN
INFLUENCE_ADMIN
MAP_ADMIN
BROADCAST_ADMIN
CONTENT_ADMIN
MEDIA_ADMIN
PAYMENTS_ADMIN
EVENTS_ADMIN
SOCIAL_ADMIN
USER
TEMP
}
enum UserStatus {
ACTIVE
INACTIVE
SUSPENDED
EXPIRED
PENDING_VERIFICATION
PENDING_APPROVAL
}
enum UserCreatedVia {
ADMIN
PUBLIC_SHIFT_SIGNUP
STANDARD
SELF_REGISTRATION
QUICK_JOIN_INVITE
}
model User {
id String @id @default(cuid())
email String @unique
password String // bcrypt hashed
name String?
phone String?
pronouns String?
role UserRole @default(USER)
roles Json @default("[]") // Array of UserRole strings for multi-role support
status UserStatus @default(ACTIVE)
permissions Json? // Per-app granular permissions
createdVia UserCreatedVia @default(STANDARD)
expiresAt DateTime? // For temp users
expireDays Int?
lastLoginAt DateTime?
emailVerified Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
refreshTokens RefreshToken[]
campaignsCreated Campaign[] @relation("CampaignCreator")
campaignsReviewed Campaign[] @relation("CampaignReviewer")
campaignEmails CampaignEmail[] @relation("CampaignEmailSender")
responses RepresentativeResponse[] @relation("ResponseSubmitter")
responseUpvotes ResponseUpvote[]
shiftSignups ShiftSignup[]
locationsCreated Location[] @relation("LocationCreator")
locationsUpdated Location[] @relation("LocationUpdater")
addressesCreated Address[] @relation("AddressCreator")
addressesUpdated Address[] @relation("AddressUpdater")
locationEdits LocationHistory[] @relation("LocationHistoryUser")
cutsCreated Cut[] @relation("CutCreator")
canvassVisits CanvassVisit[] @relation("CanvassVisitor")
canvassSessions CanvassSession[] @relation("CanvassSessions")
trackingSessions TrackingSession[] @relation("TrackingSessions")
templatesCreated EmailTemplate[] @relation("TemplatesCreated")
templatesUpdated EmailTemplate[] @relation("TemplatesUpdated")
templateVersionsCreated EmailTemplateVersion[] @relation("TemplateVersionsCreated")
templateTestsSent EmailTemplateTestLog[] @relation("TemplateTestsSent")
// Media API relations
videosUploaded Video[] @relation("VideoUploader")
videosLocked Video[] @relation("VideoLocker")
videoViews VideoView[] @relation("VideoViews")
videoScheduleHistory VideoScheduleHistory[] @relation("VideoScheduleHistory")
sessions Session[] @relation("SessionUser")
comments Comment[] @relation("CommentUser")
authTokens AuthToken[] @relation("AuthTokenUser")
sessionBansMade SessionBan[] @relation("SessionBanner")
commentModerations CommentModeration[] @relation("CommentModerator")
emailVerificationTokens EmailVerificationToken[] @relation("EmailVerificationTokens")
passwordResetTokens PasswordResetToken[] @relation("PasswordResetTokens")
emailChangeTokens EmailChangeToken[] @relation("EmailChangeTokens")
achievements UserAchievement[] @relation("UserAchievements")
stats UserStats? @relation("UserStats")
finishes UserFinish[] @relation("UserFinishes")
videoReactions VideoReaction[] @relation("VideoReactions")
highlightCooldowns HighlightCooldown[] @relation("HighlightCooldowns")
dailyActivity UserDailyActivity[] @relation("UserDailyActivity")
chatThreadReadStatus ChatThreadReadStatus[] @relation("ChatThreadReadStatus")
moderationWordLists ModerationWordList[] @relation("ModerationWordListCreator")
contentReportsSubmitted ContentReport[] @relation("ContentReportUser")
contentReportsResolved ContentReport[] @relation("ContentReportResolver")
playlists Playlist[] @relation("UserPlaylists")
featuredPlaylists FeaturedPlaylist[] @relation("FeaturedPlaylistFeaturer")
adImpressions AdImpression[] @relation("AdImpressionUser")
adClicks AdClick[] @relation("AdClickUser")
friendships Friendship[] @relation("UserFriendships")
friends Friendship[] @relation("UserFriends")
blocks UserBlock[] @relation("UserBlocks")
blockedBy UserBlock[] @relation("UserBlockedBy")
pokesSent Poke[] @relation("PokesSent")
pokesReceived Poke[] @relation("PokesReceived")
recommendationsSent VideoRecommendation[] @relation("RecommendationsSent")
recommendationsReceived VideoRecommendation[] @relation("RecommendationsReceived")
presence UserPresence? @relation("UserPresence")
galleryImages UserGalleryImage[] @relation("UserGalleryImages")
socialLinks UserSocialLink[] @relation("UserSocialLinks")
privacySettings PrivacySettings? @relation("PrivacySettings")
closeFriends CloseFriend[] @relation("CloseFriends")
closeFriendOf CloseFriend[] @relation("CloseFriendOf")
socialGroupMemberships SocialGroupMember[] @relation("SocialGroupMember")
uploads UserUpload[] @relation("UserUploads")
uploadReviews UserUpload[] @relation("UserUploadReviewer")
uploadInvites UploadInvite[] @relation("UploadInviteCreator")
tagPreferences UserTagPreference[] @relation("UserTagPreferences")
performerDiscrepancies PerformerDiscrepancy[] @relation("PerformerDiscrepancyResolver")
watchPartiesHosted WatchPartySession[] @relation("WatchPartyHost")
watchPartyParticipations WatchPartyParticipant[] @relation("WatchPartyParticipant")
watchPartyChatMessages WatchPartyChatMessage[] @relation("WatchPartyChatUser")
watchPartyReactions WatchPartyReaction[] @relation("WatchPartyReactionUser")
watchPartyInvitesSent WatchPartyInvite[] @relation("WatchPartyInviter")
watchPartyInvitesReceived WatchPartyInvite[] @relation("WatchPartyInvitee")
subscriptions UserSubscription[] @relation("UserSubscriptions")
invoices Invoice[] @relation("UserInvoices")
payments Payment[] @relation("UserPayments")
paymentAudits PaymentAuditLog[] @relation("PaymentAuditUser")
orders Order[] @relation("UserOrders")
notifications Notification[] @relation("UserNotifications")
notificationPreferences NotificationPreferences? @relation("NotificationPreferences")
// Photo gallery relations
photosUploaded Photo[] @relation("PhotoUploader")
albumsCreated PhotoAlbum[] @relation("AlbumCreator")
photoComments PhotoComment[] @relation("PhotoCommentUser")
// SMS campaign relations
smsContactListsCreated SmsContactList[] @relation("SmsContactListCreator")
smsCampaignsCreated SmsCampaign[] @relation("SmsCampaignCreator")
smsTemplatesCreated SmsMessageTemplate[] @relation("SmsTemplateCreator")
// Donation pages
donationPagesCreated DonationPage[] @relation("DonationPageCreator")
// Meetings (Jitsi)
meetingsCreated Meeting[] @relation("MeetingCreator")
// People CRM
contact Contact? @relation("UserContact")
// Scheduling polls
schedulingPollsCreated SchedulingPoll[] @relation("PollCreator")
schedulingPollVotes SchedulingPollVote[] @relation("PollVoter")
schedulingPollComments SchedulingPollComment[] @relation("PollCommenter")
// Participant needs
participantNeeds ParticipantNeeds? @relation("UserParticipantNeeds")
// Meeting agendas & action items
agendasCreated MeetingAgenda[] @relation("AgendaCreator")
minutesCreated MeetingMinutes[] @relation("MinutesCreator")
minutesApproved MeetingMinutes[] @relation("MinutesApprover")
actionItemsAssigned ActionItem[] @relation("ActionItemAssignee")
actionItemsCreated ActionItem[] @relation("ActionItemCreator")
// Referral system
inviteCodesCreated InviteCode[] @relation("InviteCodesCreated")
referralsMade Referral[] @relation("ReferralsMade")
referredBy Referral? @relation("ReferredBy")
// Impact Stories
impactStoriesCreated ImpactStory[] @relation("ImpactStoryCreator")
// Volunteer Spotlight
spotlights VolunteerSpotlight[] @relation("SpotlightUser")
spotlightNominations VolunteerSpotlight[] @relation("SpotlightNominator")
spotlightApprovals VolunteerSpotlight[] @relation("SpotlightApprover")
// Team Challenges
challengesCreated Challenge[] @relation("ChallengesCreated")
challengeTeamsCaptained ChallengeTeam[] @relation("ChallengeTeamsCaptained")
challengeParticipations ChallengeTeamMember[] @relation("ChallengeParticipations")
// Ticketed Events
ticketedEventsCreated TicketedEvent[] @relation("EventCreator")
ticketsHeld Ticket[] @relation("TicketHolder")
checkInsMade CheckIn[] @relation("CheckInUser")
// Social Calendar
calendarLayers CalendarLayer[] @relation("CalendarLayerOwner")
calendarItems CalendarItem[] @relation("CalendarItemOwner")
calendarFeeds CalendarFeed[] @relation("CalendarFeedOwner")
sharedCalendarViewsOwned SharedCalendarView[] @relation("SharedViewOwner")
sharedCalendarMemberships SharedCalendarMember[] @relation("SharedViewMember")
sharedViewComments SharedViewComment[] @relation("SharedViewCommentUser")
sharedViewReactions SharedViewReaction[] @relation("SharedViewReactionUser")
calendarExportTokens CalendarExportToken[] @relation("CalendarExportTokenOwner")
@@map("users")
}
model RefreshToken {
id String @id @default(cuid())
token String @unique
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
@@map("refresh_tokens")
}
// ============================================================================
// INFLUENCE — CAMPAIGNS
// ============================================================================
enum CampaignStatus {
DRAFT
ACTIVE
PAUSED
ARCHIVED
}
enum CampaignModerationStatus {
PENDING_REVIEW
APPROVED
REJECTED
CHANGES_REQUESTED
}
enum GovernmentLevel {
FEDERAL
PROVINCIAL
MUNICIPAL
SCHOOL_BOARD
}
model Campaign {
id String @id @default(cuid())
slug String @unique
title String
description String? @db.Text
emailSubject String
emailBody String @db.Text
callToAction String? @db.Text
coverPhoto String?
coverVideoId Int?
status CampaignStatus @default(DRAFT)
// Feature flags
allowSmtpEmail Boolean @default(true)
allowMailtoLink Boolean @default(true)
collectUserInfo Boolean @default(true)
showEmailCount Boolean @default(true)
showCallCount Boolean @default(true)
allowEmailEditing Boolean @default(false)
allowCustomRecipients Boolean @default(false)
showResponseWall Boolean @default(false)
highlightCampaign Boolean @default(false)
// Targeting
targetGovernmentLevels GovernmentLevel[]
// Creator
createdByUserId String?
createdByUser User? @relation("CampaignCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserEmail String?
createdByUserName String?
// User-generated campaign moderation
isUserGenerated Boolean @default(false)
moderationStatus CampaignModerationStatus?
reviewedByUserId String?
reviewedByUser User? @relation("CampaignReviewer", fields: [reviewedByUserId], references: [id], onDelete: SetNull)
reviewedAt DateTime?
rejectionReason String? @db.Text
moderationNotes String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
emails CampaignEmail[]
responses RepresentativeResponse[]
customRecipients CustomRecipient[]
calls Call[]
smsCampaigns SmsCampaign[] @relation("SmsCampaigns")
stories ImpactStory[] @relation("CampaignStories")
milestones CampaignMilestone[] @relation("CampaignMilestones")
donationOrders Order[] @relation("CampaignDonations")
@@index([moderationStatus])
@@index([isUserGenerated])
@@map("campaigns")
}
// ============================================================================
// INFLUENCE — REPRESENTATIVES
// ============================================================================
model Representative {
id String @id @default(cuid())
postalCode String
name String?
email String?
districtName String?
electedOffice String?
partyName String?
representativeSetName String?
url String?
photoUrl String?
offices Json? // JSON array of office contact info
cachedAt DateTime @default(now())
@@index([postalCode])
@@map("representatives")
}
// ============================================================================
// INFLUENCE — CAMPAIGN EMAILS
// ============================================================================
enum EmailMethod {
SMTP
MAILTO
}
enum CampaignEmailStatus {
QUEUED
SENT
FAILED
CLICKED
USER_INFO_CAPTURED
}
model CampaignEmail {
id String @id @default(cuid())
campaignId String
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
campaignSlug String
// Sender info
userId String?
user User? @relation("CampaignEmailSender", fields: [userId], references: [id], onDelete: SetNull)
userEmail String?
userName String?
userPostalCode String?
// Recipient info
recipientEmail String
recipientName String?
recipientTitle String?
recipientLevel GovernmentLevel?
emailMethod EmailMethod
subject String
message String @db.Text
status CampaignEmailStatus @default(SENT)
senderIp String?
sentAt DateTime @default(now())
@@index([campaignId])
@@index([campaignSlug])
@@index([userPostalCode])
@@index([sentAt])
@@map("campaign_emails")
}
// ============================================================================
// INFLUENCE — REPRESENTATIVE RESPONSES (Response Wall)
// ============================================================================
enum ResponseType {
EMAIL
LETTER
PHONE_CALL
MEETING
SOCIAL_MEDIA
OTHER
}
enum ResponseStatus {
PENDING
APPROVED
REJECTED
}
model RepresentativeResponse {
id String @id @default(cuid())
campaignId String
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
campaignSlug String
representativeName String
representativeTitle String?
representativeLevel GovernmentLevel
representativeEmail String?
responseType ResponseType
responseText String @db.Text
userComment String? @db.Text
screenshotUrl String?
// Submitter info
submittedByUserId String?
submittedByUser User? @relation("ResponseSubmitter", fields: [submittedByUserId], references: [id], onDelete: SetNull)
submittedByName String?
submittedByEmail String?
isAnonymous Boolean @default(false)
// Moderation
status ResponseStatus @default(PENDING)
// Verification
isVerified Boolean @default(false)
verificationToken String?
verificationSentAt DateTime?
verifiedAt DateTime?
verifiedBy String?
upvoteCount Int @default(0)
submittedIp String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
upvotes ResponseUpvote[]
@@index([campaignId])
@@index([campaignSlug])
@@index([representativeName])
@@map("representative_responses")
}
model ResponseUpvote {
id String @id @default(cuid())
responseId String
response RepresentativeResponse @relation(fields: [responseId], references: [id], onDelete: Cascade)
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userEmail String?
upvotedIp String?
@@unique([responseId, userId])
@@unique([responseId, upvotedIp])
@@map("response_upvotes")
}
// ============================================================================
// INFLUENCE — CUSTOM RECIPIENTS
// ============================================================================
model CustomRecipient {
id String @id @default(cuid())
campaignId String
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
campaignSlug String
recipientName String
recipientEmail String
recipientTitle String?
recipientOrganization String?
notes String? @db.Text
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([campaignId])
@@map("custom_recipients")
}
// ============================================================================
// INFLUENCE — POSTAL CODE CACHE
// ============================================================================
model PostalCodeCache {
id String @id @default(cuid())
postalCode String @unique
city String?
province String?
centroidLat Decimal? @db.Decimal(10, 8)
centroidLng Decimal? @db.Decimal(11, 8)
lastUpdated DateTime @default(now())
@@map("postal_code_cache")
}
// ============================================================================
// INFLUENCE — EMAIL LOG & VERIFICATION
// ============================================================================
model EmailLog {
id String @id @default(cuid())
recipientEmail String
senderName String
senderEmail String
subject String?
message String? @db.Text
postalCode String?
status String @default("sent") // sent, failed, previewed
senderIp String?
sentAt DateTime @default(now())
@@map("email_logs")
}
model EmailVerification {
id String @id @default(cuid())
token String @unique
email String
tempCampaignData String? @db.Text // JSON
createdAt DateTime @default(now())
expiresAt DateTime
used Boolean @default(false)
@@map("email_verifications")
}
// ============================================================================
// INFLUENCE — CALL TRACKING
// ============================================================================
model Call {
id String @id @default(cuid())
representativeName String
representativeTitle String?
phoneNumber String
officeType String?
callerName String?
callerEmail String?
postalCode String?
campaignId String?
campaign Campaign? @relation(fields: [campaignId], references: [id], onDelete: SetNull)
campaignSlug String?
callerIp String?
calledAt DateTime @default(now())
@@index([campaignId])
@@map("calls")
}
// ============================================================================
// MAP — LOCATIONS
// ============================================================================
enum SupportLevel {
LEVEL_1 @map("1")
LEVEL_2 @map("2")
LEVEL_3 @map("3")
LEVEL_4 @map("4")
}
enum GeocodeProvider {
GOOGLE
MAPBOX
NOMINATIM
PHOTON
LOCATIONIQ
ARCGIS
UNKNOWN
}
enum BuildingType {
SINGLE_FAMILY
MULTI_UNIT
MIXED_USE
COMMERCIAL
}
model Location {
id String @id @default(cuid())
latitude Decimal @db.Decimal(10, 8) // Required (was nullable)
longitude Decimal @db.Decimal(11, 8) // Required (was nullable)
// Building-level data
address String // Base street address (no unit number)
postalCode String?
province String?
federalDistrict String?
buildingUse Int? // NAR BU_USE: 1=Residential, 2=Partial, 3=Non-Residential, 4=Unknown
// NAR + building metadata
locGuid String? @unique
buildingType BuildingType @default(SINGLE_FAMILY)
totalUnits Int @default(1)
buildingNotes String? @db.Text // Access codes, manager contact, etc.
// Geocoding
geocodeConfidence Int? // 0-100
geocodeProvider GeocodeProvider?
// Audit
createdByUserId String?
createdByUser User? @relation("LocationCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
updatedByUser User? @relation("LocationUpdater", fields: [updatedByUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
addresses Address[]
history LocationHistory[]
@@index([latitude, longitude])
@@index([latitude])
@@index([longitude])
@@index([postalCode])
@@map("locations")
}
model Address {
id String @id @default(cuid())
locationId String
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
// Unit identification
unitNumber String?
addrGuid String? @unique // NAR ADDR_GUID
// Occupant/contact info (per-unit)
firstName String?
lastName String?
email String?
phone String?
// Canvassing data (per-unit)
supportLevel SupportLevel?
sign Boolean @default(false)
signSize String?
notes String? @db.Text
// Audit
createdByUserId String?
createdByUser User? @relation("AddressCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
updatedByUser User? @relation("AddressUpdater", fields: [updatedByUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
canvassVisits CanvassVisit[]
contactAddresses ContactAddress[]
@@index([locationId])
@@index([locationId, id])
@@index([locationId, unitNumber])
@@map("addresses")
}
// ============================================================================
// MAP — LOCATION HISTORY
// ============================================================================
enum LocationHistoryAction {
CREATED
UPDATED
GEOCODED
BULK_GEOCODED
MOVED_ON_MAP
IMPORTED_CSV
IMPORTED_NAR
}
model LocationHistory {
id String @id @default(cuid())
locationId String
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
userId String?
user User? @relation("LocationHistoryUser", fields: [userId], references: [id], onDelete: SetNull)
action LocationHistoryAction
field String? // Which field changed
oldValue String? @db.Text
newValue String? @db.Text
metadata Json? // Provider, confidence, etc.
createdAt DateTime @default(now())
@@index([locationId])
@@index([userId])
@@index([createdAt])
@@map("location_history")
}
// ============================================================================
// MAP — SHIFTS
// ============================================================================
enum ShiftStatus {
OPEN
FULL
CANCELLED
}
enum RecurrenceFrequency {
DAILY
WEEKLY
MONTHLY
}
model Shift {
id String @id @default(cuid())
title String
description String? @db.Text
date DateTime @db.Date
startTime String // HH:MM format
endTime String // HH:MM format
location String?
maxVolunteers Int
currentVolunteers Int @default(0)
status ShiftStatus @default(OPEN)
isPublic Boolean @default(false)
cutId String?
cut Cut? @relation(fields: [cutId], references: [id], onDelete: SetNull)
// Repeating shift series
seriesId String?
series ShiftSeries? @relation(fields: [seriesId], references: [id], onDelete: SetNull)
isException Boolean @default(false)
// Gancio event sync
gancioEventId Int?
// Video briefing meeting
meetingId String? @unique
meeting Meeting? @relation("ShiftMeeting", fields: [meetingId], references: [id], onDelete: SetNull)
createdBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
signups ShiftSignup[]
canvassVisits CanvassVisit[]
canvassSessions CanvassSession[]
// Scheduling poll conversion
convertedFromPoll SchedulingPoll? @relation("PollConvertedShift")
// Meeting agenda
agenda MeetingAgenda? @relation("ShiftAgenda")
@@index([cutId])
@@index([seriesId])
@@map("shifts")
}
enum SignupStatus {
CONFIRMED
CANCELLED
}
enum SignupSource {
AUTHENTICATED
PUBLIC
ADMIN
POLL_CONVERSION
}
model ShiftSignup {
id String @id @default(cuid())
shiftId String
shift Shift @relation(fields: [shiftId], references: [id], onDelete: Cascade)
shiftTitle String?
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userEmail String
userName String?
userPhone String?
signupDate DateTime @default(now())
status SignupStatus @default(CONFIRMED)
signupSource SignupSource @default(AUTHENTICATED)
@@unique([shiftId, userEmail])
@@index([shiftId])
@@map("shift_signups")
}
// ============================================================================
// MAP — SHIFT SERIES (Repeating Shifts)
// ============================================================================
model ShiftSeries {
id String @id @default(cuid())
title String
description String? @db.Text
startTime String // HH:MM format
endTime String // HH:MM format
location String?
maxVolunteers Int
isPublic Boolean @default(false)
cutId String?
cut Cut? @relation(fields: [cutId], references: [id], onDelete: SetNull)
// Recurrence rules
frequency RecurrenceFrequency
daysOfWeek Json? // Array of day numbers: [1,3,5] for Mon/Wed/Fri
startDate DateTime @db.Date
endDate DateTime? @db.Date
// Metadata
createdBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
shifts Shift[]
@@index([cutId])
@@map("shift_series")
}
// ============================================================================
// MAP — CUTS (Geographic Polygon Overlays)
// ============================================================================
enum CutCategory {
CUSTOM
WARD
NEIGHBORHOOD
DISTRICT
}
model Cut {
id String @id @default(cuid())
name String
description String? @db.Text
color String @default("#3388ff")
opacity Decimal @default(0.3) @db.Decimal(3, 2)
category CutCategory?
isPublic Boolean @default(false)
isOfficial Boolean @default(false)
geojson String @db.Text // GeoJSON polygon data
bounds String? @db.Text // Bounding box JSON
showLocations Boolean @default(true)
exportEnabled Boolean @default(true)
assignedTo String?
filterSettings Json? // JSON filter configuration
lastCanvassed DateTime?
completionPercentage Int @default(0)
createdByUserId String?
createdByUser User? @relation("CutCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
shifts Shift[]
shiftSeries ShiftSeries[]
canvassSessions CanvassSession[]
@@map("cuts")
}
// ============================================================================
// MAP — SETTINGS
// ============================================================================
model MapSettings {
id String @id @default(cuid())
latitude Decimal? @db.Decimal(10, 8)
longitude Decimal? @db.Decimal(11, 8)
zoom Int?
walkSheetTitle String?
walkSheetSubtitle String?
walkSheetFooter String? @db.Text
qrCode1Url String?
qrCode1Label String?
qrCode2Url String?
qrCode2Label String?
qrCode3Url String?
qrCode3Label String?
publicMapEnabled Boolean @default(true)
publicShowLocations Boolean @default(true)
publicShowSupportLevels Boolean @default(true)
publicShowCuts Boolean @default(true)
publicShowEvents Boolean @default(true)
publicShowAddresses Boolean @default(true)
publicShowSignInfo Boolean @default(true)
createdBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("map_settings")
}
// ============================================================================
// SITE SETTINGS (Singleton — branding, theme, feature toggles)
// ============================================================================
model SiteSettings {
id String @id @default(cuid())
// Organization
organizationName String @default("Changemaker Lite")
organizationShortName String @default("CML")
organizationLogoUrl String?
organizationFaviconUrl String?
// Admin theme
adminColorPrimary String @default("#9d4edd")
adminColorBgBase String @default("#1a1025")
// Public theme
publicColorPrimary String @default("#3498db")
publicColorBgBase String @default("#0d1b2a")
publicColorBgContainer String @default("#1b2838")
publicHeaderGradient String @default("linear-gradient(135deg, #005a9c 0%, #007acc 100%)")
// Text
footerText String @default("Powered by Changemaker Lite")
loginSubtitle String @default("Admin")
homepageTagline String? @map("homepage_tagline")
// Email branding
emailFromName String @default("Changemaker Lite")
// SMTP configuration (overrides env vars when set; empty/0 = use env fallback)
smtpHost String @default("")
smtpPort Int @default(0)
smtpUser String @default("")
smtpPass String @default("")
smtpFromAddress String @default("")
smtpActiveProvider String @default("mailhog") // "mailhog" | "production"
emailTestMode Boolean @default(true)
testEmailRecipient String @default("")
// Registration settings
enablePublicRegistration Boolean @default(true)
enableEmailVerification Boolean @default(true)
autoApproveVerifiedUsers Boolean @default(true)
// Feature toggles
enableInfluence Boolean @default(true)
enableMap Boolean @default(true)
enableNewsletter Boolean @default(true)
enableLandingPages Boolean @default(true)
enableMediaFeatures Boolean @default(true) @map("enable_media_features")
enablePayments Boolean @default(false)
enableGalleryAds Boolean @default(false) @map("enable_gallery_ads")
enableChat Boolean @default(false) @map("enable_chat")
enableEvents Boolean @default(false) @map("enable_events")
enableDocsComments Boolean @default(false) @map("enable_docs_comments")
enableSms Boolean @default(false) @map("enable_sms")
enablePeople Boolean @default(false) @map("enable_people")
enableSocial Boolean @default(false) @map("enable_social")
enableMeet Boolean @default(false) @map("enable_meet")
enableMeetingPlanner Boolean @default(false) @map("enable_meeting_planner")
enableTicketedEvents Boolean @default(false) @map("enable_ticketed_events")
enableSocialCalendar Boolean @default(false) @map("enable_social_calendar")
enableDocsCollaboration Boolean @default(false) @map("enable_docs_collaboration")
requireEventApproval Boolean @default(true) @map("require_event_approval")
autoSyncPeopleToMap Boolean @default(false) @map("auto_sync_people_to_map")
// SMS connection config (overrides env vars when non-empty)
smsTermuxApiUrl String @default("") @map("sms_termux_api_url")
smsTermuxApiKey String @default("") @map("sms_termux_api_key") // Encrypted at rest
smsTailscaleApiKey String @default("") @map("sms_tailscale_api_key") // Encrypted at rest
smsTailscaleTailnet String @default("") @map("sms_tailscale_tailnet")
smsTailscaleDeviceId String @default("") @map("sms_tailscale_device_id")
smsTailscaleDeviceName String @default("") @map("sms_tailscale_device_name")
// Gitea Docs Comments (overrides env vars when set; empty = use env fallback)
giteaApiToken String @default("") // Encrypted at rest — Personal Access Token
giteaCommentsRepoOwner String @default("")
giteaCommentsRepoName String @default("docs-comments")
giteaOauthClientId String @default("")
giteaOauthClientSecret String @default("") // Encrypted at rest
// Notification settings
notifyAdminShiftSignup Boolean @default(true)
notifyAdminResponseSubmitted Boolean @default(true)
notifyAdminSignRequested Boolean @default(true)
notifyAdminShiftCancellation Boolean @default(true)
notifyVolunteerSessionSummary Boolean @default(true)
notifyVolunteerCancellation Boolean @default(true)
notifyVolunteerShiftReminder Boolean @default(true)
notifyVolunteerShiftThankYou Boolean @default(true)
// SMS notification settings
smsShiftReminders Boolean @default(false) @map("sms_shift_reminders")
smsShiftReminderHours Int @default(24) @map("sms_shift_reminder_hours")
smsShiftSignupConfirm Boolean @default(false) @map("sms_shift_signup_confirm")
smsVolunteerWelcome Boolean @default(false) @map("sms_volunteer_welcome")
// Re-engagement settings
notifyVolunteerReengagement Boolean @default(false) @map("notify_volunteer_reengagement")
reengagementInactiveDays Int @default(30) @map("reengagement_inactive_days")
reengagementCooldownDays Int @default(30) @map("reengagement_cooldown_days")
// Auto-upgrade settings
enableAutoUpgrade Boolean @default(false) @map("enable_auto_upgrade")
autoUpgradeSchedule String @default("daily-3am") @map("auto_upgrade_schedule")
autoUpgradePullServices Boolean @default(false) @map("auto_upgrade_pull_services")
notifyAdminAutoUpgrade Boolean @default(true) @map("notify_admin_auto_upgrade")
useRegistryForUpgrade Boolean @default(false) @map("use_registry_for_upgrade")
giteaRegistryUrl String @default("gitea.bnkops.com/admin") @map("gitea_registry_url")
// Navigation configuration (JSON: { items: NavItem[] })
navConfig Json? @map("nav_config")
// User Provisioning (centralized user management across services)
enableUserProvisioning Boolean @default(false) @map("enable_user_provisioning")
provisionGitea Boolean @default(false) @map("provision_gitea")
provisionGiteaTiming String @default("lazy") @map("provision_gitea_timing") // 'lazy' | 'eager'
provisionVaultwarden Boolean @default(false) @map("provision_vaultwarden")
provisionVaultwardenTiming String @default("lazy") @map("provision_vaultwarden_timing") // 'lazy' | 'eager'
provisionListmonk Boolean @default(true) @map("provision_listmonk")
provisionListmonkTiming String @default("eager") @map("provision_listmonk_timing") // 'lazy' | 'eager'
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("site_settings")
}
// ============================================================================
// EMAIL TEMPLATES
// ============================================================================
enum EmailTemplateCategory {
INFLUENCE
MAP
SYSTEM
PAYMENT
}
enum EmailTemplateVariableType {
TEXT
VIDEO
}
model EmailTemplate {
id String @id @default(cuid())
key String @unique // e.g., "campaign-email"
name String // Display name
description String? @db.Text
category EmailTemplateCategory // INFLUENCE | MAP | SYSTEM
subjectLine String // Template with {{VAR}} support
htmlContent String @db.Text
textContent String @db.Text
isSystem Boolean @default(false) // Prevent deletion
isActive Boolean @default(true)
variables EmailTemplateVariable[]
versions EmailTemplateVersion[]
testLogs EmailTemplateTestLog[]
createdByUserId String
createdBy User @relation("TemplatesCreated", fields: [createdByUserId], references: [id])
updatedByUserId String?
updatedBy User? @relation("TemplatesUpdated", fields: [updatedByUserId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([category])
@@index([isActive])
@@map("email_templates")
}
model EmailTemplateVariable {
id String @id @default(cuid())
templateId String
template EmailTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
key String // e.g., "USER_NAME"
label String // e.g., "User Name"
description String? @db.Text // e.g., "Name of the user sending the email"
type EmailTemplateVariableType @default(TEXT) // TEXT | VIDEO
videoId Int? // Optional FK to videos (not enforced, separate Media API DB)
isRequired Boolean @default(true)
isConditional Boolean @default(false) // Used in {{#if}} blocks
sampleValue String? @db.Text // e.g., "John Doe"
sortOrder Int @default(0)
@@unique([templateId, key])
@@index([templateId])
@@index([type])
@@map("email_template_variables")
}
model EmailTemplateVersion {
id String @id @default(cuid())
templateId String
template EmailTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
versionNumber Int // Auto-increment per template
subjectLine String
htmlContent String @db.Text
textContent String @db.Text
changeNotes String? @db.Text
createdByUserId String
createdBy User @relation("TemplateVersionsCreated", fields: [createdByUserId], references: [id])
createdAt DateTime @default(now())
@@unique([templateId, versionNumber])
@@index([templateId, createdAt(sort: Desc)])
@@map("email_template_versions")
}
model EmailTemplateTestLog {
id String @id @default(cuid())
templateId String
template EmailTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
recipientEmail String
testData Json // Sample variable values used
success Boolean
errorMessage String? @db.Text
messageId String? // Nodemailer message ID
sentByUserId String
sentBy User @relation("TemplateTestsSent", fields: [sentByUserId], references: [id])
sentAt DateTime @default(now())
@@index([templateId, sentAt(sort: Desc)])
@@map("email_template_test_logs")
}
// ============================================================================
// LANDING PAGES
// ============================================================================
enum EditorMode {
VISUAL
CODE
}
enum MkdocsExportMode {
THEMED // extends main.html, content block only
STANDALONE // full HTML document, no Jinja2 inheritance
}
model LandingPage {
id String @id @default(cuid())
slug String @unique
title String
description String? @db.Text
blocks Json // JSON from GrapesJS editor
htmlOutput String? @db.Text
cssOutput String? @db.Text
editorMode EditorMode @default(VISUAL)
mkdocsPath String? // Path in mkdocs/overrides/
mkdocsStubPath String? // Path to .md stub in mkdocs/docs/
mkdocsExportMode MkdocsExportMode @default(THEMED)
mkdocsHideNav Boolean @default(true)
mkdocsHideToc Boolean @default(true)
mkdocsSkipExport Boolean @default(false)
published Boolean @default(false)
listed Boolean @default(false)
seoTitle String?
seoDescription String? @db.Text
seoImage String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("landing_pages")
}
model PageBlock {
id String @id @default(cuid())
type String // hero, text, image, cta, features, testimonials, form
label String
schema Json // Block configuration schema
defaults Json // Default values
thumbnail String?
category String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("page_blocks")
}
// ============================================================================
// MAP — CANVASSING
// ============================================================================
enum VisitOutcome {
NOT_HOME
REFUSED
MOVED
ALREADY_VOTED
SPOKE_WITH
LEFT_LITERATURE
COME_BACK_LATER
}
enum CanvassSessionStatus {
ACTIVE
COMPLETED
ABANDONED
}
model CanvassSession {
id String @id @default(cuid())
userId String
user User @relation("CanvassSessions", fields: [userId], references: [id], onDelete: Cascade)
cutId String
cut Cut @relation(fields: [cutId], references: [id], onDelete: Cascade)
shiftId String?
shift Shift? @relation(fields: [shiftId], references: [id], onDelete: SetNull)
status CanvassSessionStatus @default(ACTIVE)
startedAt DateTime @default(now())
endedAt DateTime?
startLatitude Decimal? @db.Decimal(10, 8)
startLongitude Decimal? @db.Decimal(11, 8)
visits CanvassVisit[]
trackingSession TrackingSession?
@@index([userId])
@@index([cutId])
@@index([shiftId])
@@map("canvass_sessions")
}
model CanvassVisit {
id String @id @default(cuid())
addressId String // Changed from locationId
address Address @relation(fields: [addressId], references: [id], onDelete: Cascade)
userId String
user User @relation("CanvassVisitor", fields: [userId], references: [id], onDelete: Cascade)
shiftId String?
shift Shift? @relation(fields: [shiftId], references: [id], onDelete: SetNull)
sessionId String?
session CanvassSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
outcome VisitOutcome
supportLevel SupportLevel?
signRequested Boolean @default(false)
signSize String?
notes String? @db.Text
durationSeconds Int?
visitedAt DateTime @default(now())
@@index([addressId]) // Changed from locationId
@@index([addressId, visitedAt(sort: Desc)]) // For distinct + orderBy queries
@@index([userId])
@@index([shiftId])
@@index([sessionId])
@@index([visitedAt])
@@map("canvass_visits")
}
// ============================================================================
// MAP — GPS TRACKING
// ============================================================================
enum TrackPointEvent {
LOCATION_ADDED
VISIT_RECORDED
SESSION_STARTED
SESSION_ENDED
}
model TrackingSession {
id String @id @default(cuid())
userId String
user User @relation("TrackingSessions", fields: [userId], references: [id], onDelete: Cascade)
canvassSessionId String? @unique
canvassSession CanvassSession? @relation(fields: [canvassSessionId], references: [id], onDelete: SetNull)
startedAt DateTime @default(now())
endedAt DateTime?
isActive Boolean @default(true)
totalPoints Int @default(0)
totalDistanceM Float @default(0)
lastLatitude Decimal? @db.Decimal(10, 8)
lastLongitude Decimal? @db.Decimal(11, 8)
lastRecordedAt DateTime?
trackPoints TrackPoint[]
@@index([userId])
@@index([isActive])
@@index([isActive, lastRecordedAt])
@@map("tracking_sessions")
}
model TrackPoint {
id String @id @default(cuid())
trackingSessionId String
trackingSession TrackingSession @relation(fields: [trackingSessionId], references: [id], onDelete: Cascade)
latitude Decimal @db.Decimal(10, 8)
longitude Decimal @db.Decimal(11, 8)
accuracy Float?
recordedAt DateTime @default(now())
eventType TrackPointEvent?
@@index([trackingSessionId, recordedAt])
@@index([recordedAt])
@@map("track_points")
}
// Enums
enum DirectoryType {
studios
gifs
private
inbox
curated
playback
compilations
videos
highlights
}
enum ResourceCategory {
gpu_ai
gpu_encode
cpu
}
enum JobStatus {
pending
queued
running
completed
failed
cancelled
}
enum ReactionType {
like
love
laugh
wow
sad
angry
}
enum WordFilterLevel {
low
medium
high
custom
}
enum ReportType {
inappropriate
spam
copyright
illegal
false_info
other
}
enum ReportStatus {
pending
reviewed
actioned
dismissed
}
enum FriendshipStatus {
pending
accepted
declined
}
enum SocialPlatform {
twitter
instagram
onlyfans
fansly
reddit
discord
tiktok
youtube
twitch
snapchat
linktree
custom
}
enum UserUploadStatus {
pending
approved
rejected
}
enum UploadInviteStatus {
active
inactive
expired
}
enum DigestStatus {
pending
scene_detection
extracting
analyzing
face_detection
transcribing
segmenting
synthesizing
completed
failed
cancelled
}
enum ClipType {
hook
intro
action
climax
highlight
}
enum ClipStatus {
pending
processing
completed
failed
}
enum ClipSource {
machine
manual
}
enum CaptionPosition {
bottom
top
}
enum CaptionSize {
small
medium
large
}
enum SegmentType {
scene
tag_change
vocal
fixed_interval
}
enum VocalCategory {
dialogue
dirty_talk
climax
interview
moan
}
enum SuggestedTagStatus {
pending
approved
rejected
mapped
}
enum WatchPartyStatus {
active
completed
cancelled
}
enum PipelineStatus {
draft
active
paused
completed
failed
}
enum PipelineStepStatus {
pending
running
completed
failed
skipped
}
enum SubscriptionStatus {
none
active
grace_period
delinquent
lifetime
cancelled
}
enum InvoiceStatus {
pending
paid
failed
refunded
}
enum PaymentStatus {
pending
succeeded
failed
refunded
}
enum PaymentMethod {
card
bank_transfer
crypto
stripe
}
enum ProductType {
DIGITAL
EVENT
DONATION
}
enum OrderStatus {
PENDING
COMPLETED
FAILED
REFUNDED
}
enum NotificationType {
friend_request
friend_accepted
poke
comment
upload_approved
upload_rejected
achievement
system
group_call
impact_story
referral_completed
challenge_update
shared_view_invite
shared_view_accepted
calendar_event_invite
// Operational notification types
shift_signup_confirmed
shift_reminder
shift_cancelled
canvass_session_summary
reengagement
}
// ============================================================================
// CORE VIDEO LIBRARY
// ============================================================================
model Video {
id Int @id @default(autoincrement())
path String @unique
filename String
producer String?
creator String?
title String?
durationSeconds Int? @map("duration_seconds")
quality String?
orientation String?
hasAudio Boolean? @default(true) @map("has_audio")
fileSize BigInt? @map("file_size")
fileHash String? @map("file_hash")
width Int?
height Int?
lastValidated DateTime? @map("last_validated")
isValid Boolean? @default(true) @map("is_valid")
thumbnailPath String? @map("thumbnail_path")
createdAt DateTime @default(now()) @map("created_at")
tags Json?
directoryType DirectoryType? @map("directory_type")
// Historical engagement stats
publicViewCount Int? @map("public_view_count")
publicUpvoteCount Int? @map("public_upvote_count")
publicCommentCount Int? @map("public_comment_count")
publicCompletionCount Int? @map("public_completion_count")
publicTotalWatchTime Int? @map("public_total_watch_time")
movedFromPublicAt DateTime? @map("moved_from_public_at")
// Name standardization tracking
originalFilename String? @map("original_filename")
originalPath String? @map("original_path")
standardizedAt DateTime? @map("standardized_at")
// Publishing system (replaces PublicMedia)
isPublished Boolean @default(false) @map("is_published")
publishedAt DateTime? @map("published_at")
category String? // videos|curated|compilations|playback|highlights
isShort Boolean @default(false) @map("is_short")
// Moderation system
isLocked Boolean @default(false) @map("is_locked")
lockedAt DateTime? @map("locked_at")
lockedById String? @map("locked_by_id")
// Engagement counters
viewCount Int @default(0) @map("view_count")
upvoteCount Int @default(0) @map("upvote_count")
commentCount Int @default(0) @map("comment_count")
finishCount Int @default(0) @map("finish_count")
totalWatchTime Int @default(0) @map("total_watch_time")
// Scheduled publishing
scheduledPublishAt DateTime? @map("scheduled_publish_at")
scheduledUnpublishAt DateTime? @map("scheduled_unpublish_at")
// Enhanced analytics
uniqueViewers Int @default(0) @map("unique_viewers")
totalWatchTimeSeconds Int @default(0) @map("total_watch_time_seconds")
averageWatchTimeSeconds Decimal @default(0) @map("average_watch_time_seconds") @db.Decimal(10, 2)
completionRate Decimal @default(0) @map("completion_rate") @db.Decimal(5, 2)
// Content gating
accessLevel String @default("free") @map("access_level") // free|member|premium
// Ordering
position Int? @default(0)
// Uploader tracking
uploaderId String? @map("uploader_id")
// Relations
uploader User? @relation("VideoUploader", fields: [uploaderId], references: [id])
locker User? @relation("VideoLocker", fields: [lockedById], references: [id])
upvotes Upvote[]
comments Comment[]
views View[]
reactions VideoReaction[]
finishes UserFinish[]
contentReports ContentReport[]
playlistVideos PlaylistVideo[]
publicMediaTags PublicMediaTag[] @relation("PublicMediaTags")
publicMediaPerformers PublicMediaPerformer[] @relation("PublicMediaPerformers")
recommendations VideoRecommendation[]
videoDigests VideoDigest[]
digestVideoTags DigestVideoTag[]
digestSelectedClips DigestSelectedClip[]
digestGeneratedScenes DigestGeneratedScene[]
digestOutputFolders DigestOutputFolder[]
digestCompilations DigestCompilation[]
videoSceneCuts VideoSceneCut[]
videoTagTimeline VideoTagTimeline[]
videoSegments VideoSegment[]
videoTags VideoTag[]
digestClipTags DigestClipTag[]
tagGenerationJobs TagGenerationJob[]
videoOcrResults VideoOcrResult[]
videoViews VideoView[]
videoEvents VideoEvent[]
scheduleHistory VideoScheduleHistory[]
@@index([orientation], map: "idx_orientation")
@@index([producer], map: "idx_producer")
@@index([isValid], map: "idx_is_valid")
@@index([directoryType], map: "idx_directory_type")
@@index([durationSeconds, fileSize, width, height], map: "idx_videos_fingerprint")
@@index([directoryType, isValid, orientation], map: "idx_videos_directory_valid_orientation")
@@index([isPublished, isLocked], map: "idx_videos_published_locked")
@@index([category, isPublished], map: "idx_videos_category_published")
@@index([isShort, isPublished, isLocked], map: "idx_videos_short_published")
@@index([uploaderId], map: "idx_videos_uploader")
@@map("videos")
}
model Compilation {
id Int @id @default(autoincrement())
filename String
path String?
durationSeconds Int? @map("duration_seconds")
videoIds Json? @map("video_ids")
settings Json?
createdAt DateTime @default(now()) @map("created_at")
@@map("compilations")
}
model Job {
id Int @id @default(autoincrement())
type String
status JobStatus? @default(pending)
progress Int? @default(0)
log String?
params Json?
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
// Queue management
resourceCategory ResourceCategory? @default(cpu) @map("resource_category")
vramRequired Int? @default(0) @map("vram_required")
queuePosition Int? @map("queue_position")
waitingReason String? @map("waiting_reason")
priority Int? @default(5)
// Pipeline integration
pipelineId Int? @map("pipeline_id")
pipelineStepId Int? @map("pipeline_step_id")
// Relations
pipeline Pipeline? @relation(fields: [pipelineId], references: [id])
pipelineStep PipelineStep? @relation(fields: [pipelineStepId], references: [id])
videoDigest VideoDigest?
tagGenerationJobs TagGenerationJob[]
@@index([status, priority, createdAt], map: "idx_jobs_queue")
@@index([resourceCategory, status], map: "idx_jobs_resource")
@@index([pipelineId], map: "idx_jobs_pipeline")
@@map("jobs")
}
// ============================================================================
// PUBLIC GALLERY (DEPRECATED - Consolidated into Video model)
// ============================================================================
// NOTE: PublicMedia model has been removed - all functionality consolidated into Video model
// with isPublished, category, isLocked, and engagement counter fields.
// Migration path: All existing PublicMedia data should be migrated to Video table with
// isPublished=true before dropping this table.
// COMMENTED OUT - SCHEDULED FOR DELETION AFTER DATA MIGRATION
// model PublicMedia {
// id Int @id @default(autoincrement())
// path String @unique
// filename String
// category String
// durationSeconds Int? @map("duration_seconds")
// quality String?
// orientation String?
// thumbnailPath String? @map("thumbnail_path")
// fileSize BigInt? @map("file_size")
// viewCount Int? @default(0) @map("view_count")
// upvoteCount Int? @default(0) @map("upvote_count")
// commentCount Int? @default(0) @map("comment_count")
// finishCount Int? @default(0) @map("finish_count")
// totalWatchTime Int? @default(0) @map("total_watch_time")
// createdAt DateTime @default(now()) @map("created_at")
// isLocked Boolean? @default(false) @map("is_locked")
// lockedAt DateTime? @map("locked_at")
// lockedBy String? @map("locked_by")
// position Int? @default(0)
// uploaderId String? @map("uploader_id")
//
// @@index([category], map: "idx_public_media_category")
// @@index([path], map: "idx_public_media_path")
// @@index([isLocked], map: "idx_public_media_is_locked")
// @@index([position], map: "idx_public_media_position")
// @@index([uploaderId], map: "idx_public_media_uploader")
// @@index([category, createdAt], map: "idx_public_media_category_date")
// @@index([orientation], map: "idx_public_media_orientation")
// @@index([category, isLocked, createdAt], map: "idx_public_media_category_locked_date")
// @@map("public_media")
// }
model Session {
id String @id
createdAt DateTime @default(now()) @map("created_at")
lastSeenAt DateTime? @map("last_seen_at")
// Device tracking
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent")
deviceType String? @map("device_type")
browser String?
os String?
// Geography
country String?
countryName String? @map("country_name")
region String?
city String?
timezone String?
latitude Float? @db.Real
longitude Float? @db.Real
// User correlation
userId String? @map("user_id")
// Analytics
firstSeenAt DateTime? @map("first_seen_at")
visitCount Int? @default(1) @map("visit_count")
// Relations
user User? @relation("SessionUser", fields: [userId], references: [id])
upvotes Upvote[]
comments Comment[]
views View[]
sessionBans SessionBan[]
contentReports ContentReport[]
playlistViews PlaylistView[]
adImpressions AdImpression[]
adClicks AdClick[]
userFinishes UserFinish[]
// Photo gallery relations
photoUpvotes PhotoUpvote[] @relation("SessionPhotoUpvotes")
photoComments PhotoComment[] @relation("SessionPhotoComments")
photoReactions PhotoReaction[] @relation("SessionPhotoReactions")
@@index([userId], map: "idx_sessions_user_id")
@@index([country], map: "idx_sessions_country")
@@map("sessions")
}
model Upvote {
id Int @id @default(autoincrement())
mediaId Int @map("media_id")
sessionId String @map("session_id")
createdAt DateTime @default(now()) @map("created_at")
// Relations
media Video @relation(fields: [mediaId], references: [id])
session Session @relation(fields: [sessionId], references: [id])
@@index([mediaId, sessionId], map: "idx_upvotes_unique")
@@index([mediaId], map: "idx_upvotes_media")
@@map("upvotes")
}
model Comment {
id Int @id @default(autoincrement())
mediaId Int @map("media_id")
sessionId String @map("session_id")
userId String? @map("user_id")
content String
createdAt DateTime @default(now()) @map("created_at")
// Content safety fields
safetyStatus String? @default("pending") @map("safety_status")
safetyCheckedAt DateTime? @map("safety_checked_at")
safetyCategories Json? @map("safety_categories")
safetyReasoning String? @map("safety_reasoning")
// Hidden comment fields
isHidden Boolean? @default(false) @map("is_hidden")
hiddenAt DateTime? @map("hidden_at")
hiddenReason String? @map("hidden_reason")
moderationNotes String? @map("moderation_notes")
// Relations
media Video @relation(fields: [mediaId], references: [id])
session Session @relation(fields: [sessionId], references: [id])
user User? @relation("CommentUser", fields: [userId], references: [id])
moderation CommentModeration?
watchPartyMessages WatchPartyChatMessage[]
@@index([mediaId], map: "idx_comments_media")
@@index([sessionId], map: "idx_comments_session")
@@index([userId], map: "idx_comments_user")
@@index([safetyStatus], map: "idx_comments_safety_status")
@@index([isHidden], map: "idx_comments_is_hidden")
@@map("comments")
}
model View {
id Int @id @default(autoincrement())
mediaId Int @map("media_id")
sessionId String @map("session_id")
watchTimeSeconds Int? @default(0) @map("watch_time_seconds")
lastUpdated DateTime @default(now()) @map("last_updated")
createdAt DateTime @default(now()) @map("created_at")
// Relations
media Video @relation(fields: [mediaId], references: [id])
session Session @relation(fields: [sessionId], references: [id])
@@index([mediaId, sessionId], map: "idx_views_unique")
@@index([mediaId], map: "idx_views_media")
@@map("views")
}
// ============================================================================
// USER MANAGEMENT (EXCLUDING v2Users and mediaUsers)
// ============================================================================
model AuthToken {
id Int @id @default(autoincrement())
userId String @map("user_id")
token String @unique
type String
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
// Relations
user User @relation("AuthTokenUser", fields: [userId], references: [id])
@@index([token], map: "idx_auth_tokens_token")
@@index([userId], map: "idx_auth_tokens_user")
@@map("auth_tokens")
}
model SessionBan {
id Int @id @default(autoincrement())
sessionId String @map("session_id")
reason String?
bannedBy String? @map("banned_by")
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime? @map("expires_at")
// Relations
session Session @relation(fields: [sessionId], references: [id])
banner User? @relation("SessionBanner", fields: [bannedBy], references: [id])
@@index([sessionId], map: "idx_session_bans_session")
@@map("session_bans")
}
model CommentModeration {
id Int @id @default(autoincrement())
commentId Int @unique @map("comment_id")
status String @default("pending")
moderatedBy String? @map("moderated_by")
moderatedAt DateTime? @map("moderated_at")
reason String?
// Relations
comment Comment @relation(fields: [commentId], references: [id])
moderator User? @relation("CommentModerator", fields: [moderatedBy], references: [id])
@@index([commentId], map: "idx_comment_moderation_comment")
@@index([status], map: "idx_comment_moderation_status")
@@map("comment_moderation")
}
model EmailVerificationToken {
id Int @id @default(autoincrement())
userId String @map("user_id")
token String @unique
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
// Relations
user User @relation("EmailVerificationTokens", fields: [userId], references: [id])
@@index([token], map: "idx_email_verification_tokens_token")
@@index([userId], map: "idx_email_verification_tokens_user")
@@map("email_verification_tokens")
}
model PasswordResetToken {
id Int @id @default(autoincrement())
userId String @map("user_id")
token String @unique
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
usedAt DateTime? @map("used_at")
// Relations
user User @relation("PasswordResetTokens", fields: [userId], references: [id])
@@index([token], map: "idx_password_reset_tokens_token")
@@index([userId], map: "idx_password_reset_tokens_user")
@@map("password_reset_tokens")
}
model EmailChangeToken {
id Int @id @default(autoincrement())
userId String @map("user_id")
newEmail String @map("new_email")
token String @unique
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
// Relations
user User @relation("EmailChangeTokens", fields: [userId], references: [id])
@@index([token], map: "idx_email_change_tokens_token")
@@index([userId], map: "idx_email_change_tokens_user")
@@map("email_change_tokens")
}
// ============================================================================
// ACHIEVEMENTS & DASHBOARD
// ============================================================================
model UserAchievement {
id Int @id @default(autoincrement())
userId String @map("user_id")
achievementId String @map("achievement_id")
unlockedAt DateTime @map("unlocked_at")
progress Int? @default(0)
notified Boolean? @default(false)
// Relations
user User @relation("UserAchievements", fields: [userId], references: [id])
@@unique([userId, achievementId], map: "idx_user_achievements_unique")
@@index([userId], map: "idx_user_achievements_user")
@@map("user_achievements")
}
model UserStats {
id Int @id @default(autoincrement())
userId String @unique @map("user_id")
totalWatchTimeSeconds Int? @default(0) @map("total_watch_time_seconds")
totalVideosWatched Int? @default(0) @map("total_videos_watched")
totalUpvotesGiven Int? @default(0) @map("total_upvotes_given")
totalCommentsMade Int? @default(0) @map("total_comments_made")
totalFinishes Int? @default(0) @map("total_finishes")
currentDayStreak Int? @default(0) @map("current_day_streak")
longestDayStreak Int? @default(0) @map("longest_day_streak")
lastActiveDate String? @map("last_active_date")
longestSingleSession Int? @default(0) @map("longest_single_session")
categoriesCompleted Json? @map("categories_completed")
nightOwlCount Int? @default(0) @map("night_owl_count")
earlyBirdCount Int? @default(0) @map("early_bird_count")
updatedAt DateTime? @map("updated_at")
// Relations
user User @relation("UserStats", fields: [userId], references: [id])
@@map("user_stats")
}
model UserFinish {
id Int @id @default(autoincrement())
userId String @map("user_id")
mediaId Int? @map("media_id")
sessionId String? @map("session_id")
createdAt DateTime @map("created_at")
// Relations
user User @relation("UserFinishes", fields: [userId], references: [id])
media Video? @relation(fields: [mediaId], references: [id])
session Session? @relation(fields: [sessionId], references: [id])
@@index([userId], map: "idx_user_finishes_user")
@@index([createdAt], map: "idx_user_finishes_date")
@@map("user_finishes")
}
model VideoReaction {
id Int @id @default(autoincrement())
userId String @map("user_id")
mediaId Int @map("media_id")
reactionType ReactionType @map("reaction_type")
videoTimestamp Int @map("video_timestamp")
createdAt DateTime @default(now()) @map("created_at")
// Relations
user User @relation("VideoReactions", fields: [userId], references: [id])
media Video @relation(fields: [mediaId], references: [id])
@@index([userId, mediaId, reactionType], map: "idx_video_reactions_user_media_type")
@@index([mediaId, videoTimestamp], map: "idx_video_reactions_media_timestamp")
@@index([mediaId], map: "idx_video_reactions_media")
@@index([createdAt], map: "idx_video_reactions_created")
@@map("video_reactions")
}
model HighlightCooldown {
id Int @id @default(autoincrement())
userId String @map("user_id")
lastGeneratedAt DateTime @map("last_generated_at")
// Relations
user User @relation("HighlightCooldowns", fields: [userId], references: [id])
@@index([userId], map: "idx_highlight_cooldowns_user")
@@map("highlight_cooldowns")
}
model UserDailyActivity {
id Int @id @default(autoincrement())
userId String @map("user_id")
activityDate String @map("activity_date")
watchTimeSeconds Int? @default(0) @map("watch_time_seconds")
videosWatched Int? @default(0) @map("videos_watched")
firstActivityHour Int? @map("first_activity_hour")
createdAt DateTime? @map("created_at")
// Relations
user User @relation("UserDailyActivity", fields: [userId], references: [id])
@@index([userId, activityDate], map: "idx_user_daily_activity_unique")
@@map("user_daily_activity")
}
model ChatThreadReadStatus {
id Int @id @default(autoincrement())
userId String @map("user_id")
mediaId Int @map("media_id")
lastSeenAt DateTime @map("last_seen_at")
// Relations
user User @relation("ChatThreadReadStatus", fields: [userId], references: [id])
@@index([userId, mediaId], map: "idx_chat_thread_read_unique")
@@index([userId], map: "idx_chat_thread_read_user")
@@map("chat_thread_read_status")
}
// ============================================================================
// APP SETTINGS & MODERATION
// ============================================================================
model AppSetting {
key String @id
value String
updatedAt DateTime @default(now()) @map("updated_at")
@@map("app_settings")
}
model RateLimit {
id Int @id @default(autoincrement())
key String @unique
count Int @default(1)
resetAt DateTime @map("reset_at")
@@unique([key], map: "idx_rate_limits_key")
@@index([resetAt], map: "idx_rate_limits_reset_at")
@@map("rate_limits")
}
model ModerationWordList {
id Int @id @default(autoincrement())
level WordFilterLevel
word String
createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by")
// Relations
creator User? @relation("ModerationWordListCreator", fields: [createdBy], references: [id])
@@index([level], map: "idx_moderation_word_lists_level")
@@index([word], map: "idx_moderation_word_lists_word")
@@map("moderation_word_lists")
}
model ContentReport {
id Int @id @default(autoincrement())
mediaId Int @map("media_id")
sessionId String? @map("session_id")
userId String? @map("user_id")
reportType ReportType @map("report_type")
description String?
status ReportStatus @default(pending)
resolvedBy String? @map("resolved_by")
resolvedAt DateTime? @map("resolved_at")
resolutionNotes String? @map("resolution_notes")
createdAt DateTime @default(now()) @map("created_at")
// Relations
media Video @relation(fields: [mediaId], references: [id])
session Session? @relation(fields: [sessionId], references: [id])
user User? @relation("ContentReportUser", fields: [userId], references: [id])
resolver User? @relation("ContentReportResolver", fields: [resolvedBy], references: [id])
@@index([mediaId], map: "idx_content_reports_media")
@@index([status], map: "idx_content_reports_status")
@@index([sessionId], map: "idx_content_reports_session")
@@index([createdAt], map: "idx_content_reports_created")
@@map("content_reports")
}
// ============================================================================
// PLAYLISTS
// ============================================================================
model Playlist {
id Int @id @default(autoincrement())
userId String @map("user_id")
name String
description String?
isPublic Boolean? @default(false) @map("is_public")
shareToken String? @unique @map("share_token")
thumbnailMediaId Int? @map("thumbnail_media_id")
videoCount Int? @default(0) @map("video_count")
totalDurationSeconds Int? @default(0) @map("total_duration_seconds")
viewCount Int? @default(0) @map("view_count")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @map("updated_at")
// Relations
user User @relation("UserPlaylists", fields: [userId], references: [id])
videos PlaylistVideo[]
featured FeaturedPlaylist?
views PlaylistView[]
@@unique([userId, name], map: "idx_playlists_user_name")
@@index([userId], map: "idx_playlists_user")
@@index([isPublic], map: "idx_playlists_public")
@@index([shareToken], map: "idx_playlists_share_token")
@@map("playlists")
}
model PlaylistVideo {
id Int @id @default(autoincrement())
playlistId Int @map("playlist_id")
mediaId Int @map("media_id")
position Int @default(0)
addedAt DateTime @default(now()) @map("added_at")
// Relations
playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
media Video @relation(fields: [mediaId], references: [id])
@@index([playlistId], map: "idx_playlist_videos_playlist")
@@index([mediaId], map: "idx_playlist_videos_media")
@@index([playlistId, mediaId], map: "idx_playlist_videos_unique")
@@map("playlist_videos")
}
model FeaturedPlaylist {
id Int @id @default(autoincrement())
playlistId Int @unique @map("playlist_id")
position Int @default(0)
featuredBy String? @map("featured_by")
featuredAt DateTime? @map("featured_at")
// Relations
playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
featurer User? @relation("FeaturedPlaylistFeaturer", fields: [featuredBy], references: [id])
@@index([position], map: "idx_featured_playlists_position")
@@map("featured_playlists")
}
model PlaylistView {
id Int @id @default(autoincrement())
playlistId Int @map("playlist_id")
sessionId String @map("session_id")
createdAt DateTime @default(now()) @map("created_at")
// Relations
playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
session Session @relation(fields: [sessionId], references: [id])
@@index([playlistId], map: "idx_playlist_views_playlist")
@@index([playlistId, sessionId], map: "idx_playlist_views_unique")
@@map("playlist_views")
}
// ============================================================================
// ADVERTISEMENTS
// ============================================================================
model Ad {
id Int @id @default(autoincrement())
type String
variant String?
imagePath String? @map("image_path")
linkUrl String? @map("link_url")
title String?
subtitle String? @db.Text
ctaText String? @map("cta_text")
ctaStyle String? @default("primary") @map("cta_style")
bgColor String? @map("bg_color")
iconEmoji String? @map("icon_emoji")
isSystemAd Boolean @default(false) @map("is_system_ad")
frequency Int @default(6)
visibility String @default("everyone")
isActive Boolean? @default(true) @map("is_active")
position Int? @default(0)
impressionCount Int? @default(0) @map("impression_count")
clickCount Int? @default(0) @map("click_count")
startDate DateTime? @map("start_date")
endDate DateTime? @map("end_date")
placements Json? @default("[]")
productId String? @unique @map("product_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @map("updated_at")
// Relations
impressions AdImpression[]
clicks AdClick[]
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
@@index([type], map: "idx_ads_type")
@@index([isActive], map: "idx_ads_is_active")
@@index([visibility], map: "idx_ads_visibility")
@@map("ads")
}
model AdImpression {
id Int @id @default(autoincrement())
adId Int @map("ad_id")
sessionId String? @map("session_id")
userId String? @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
// Relations
ad Ad @relation(fields: [adId], references: [id], onDelete: Cascade)
session Session? @relation(fields: [sessionId], references: [id])
user User? @relation("AdImpressionUser", fields: [userId], references: [id])
@@index([adId], map: "idx_ad_impressions_ad")
@@index([sessionId], map: "idx_ad_impressions_session")
@@index([createdAt], map: "idx_ad_impressions_date")
@@map("ad_impressions")
}
model AdClick {
id Int @id @default(autoincrement())
adId Int @map("ad_id")
sessionId String? @map("session_id")
userId String? @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
// Relations
ad Ad @relation(fields: [adId], references: [id], onDelete: Cascade)
session Session? @relation(fields: [sessionId], references: [id])
user User? @relation("AdClickUser", fields: [userId], references: [id])
@@index([adId], map: "idx_ad_clicks_ad")
@@index([sessionId], map: "idx_ad_clicks_session")
@@index([createdAt], map: "idx_ad_clicks_date")
@@map("ad_clicks")
}
// ============================================================================
// FRIENDS & SOCIAL
// ============================================================================
model Friendship {
id Int @id @default(autoincrement())
userId String @map("user_id")
friendId String @map("friend_id")
status FriendshipStatus @default(pending)
createdAt DateTime @default(now()) @map("created_at")
acceptedAt DateTime? @map("accepted_at")
// Relations
user User @relation("UserFriendships", fields: [userId], references: [id])
friend User @relation("UserFriends", fields: [friendId], references: [id])
@@index([userId, friendId], map: "idx_friendships_user_friend")
@@index([userId], map: "idx_friendships_user")
@@index([friendId], map: "idx_friendships_friend")
@@index([status], map: "idx_friendships_status")
@@map("friendships")
}
model UserBlock {
id Int @id @default(autoincrement())
userId String @map("user_id")
blockedUserId String @map("blocked_user_id")
createdAt DateTime @default(now()) @map("created_at")
// Relations
user User @relation("UserBlocks", fields: [userId], references: [id])
blockedUser User @relation("UserBlockedBy", fields: [blockedUserId], references: [id])
@@index([userId, blockedUserId], map: "idx_user_blocks_unique")
@@index([userId], map: "idx_user_blocks_user")
@@map("user_blocks")
}
model Poke {
id Int @id @default(autoincrement())
fromUserId String @map("from_user_id")
toUserId String @map("to_user_id")
isRead Boolean? @default(false) @map("is_read")
createdAt DateTime @default(now()) @map("created_at")
// Relations
from User @relation("PokesSent", fields: [fromUserId], references: [id])
to User @relation("PokesReceived", fields: [toUserId], references: [id])
@@index([toUserId], map: "idx_pokes_to_user")
@@index([fromUserId], map: "idx_pokes_from_user")
@@map("pokes")
}
model VideoRecommendation {
id Int @id @default(autoincrement())
fromUserId String @map("from_user_id")
toUserId String @map("to_user_id")
mediaId Int @map("media_id")
message String?
isRead Boolean? @default(false) @map("is_read")
createdAt DateTime @default(now()) @map("created_at")
// Relations
from User @relation("RecommendationsSent", fields: [fromUserId], references: [id])
to User @relation("RecommendationsReceived", fields: [toUserId], references: [id])
media Video @relation(fields: [mediaId], references: [id])
@@index([toUserId], map: "idx_video_recommendations_to_user")
@@index([fromUserId], map: "idx_video_recommendations_from_user")
@@index([mediaId], map: "idx_video_recommendations_media")
@@map("video_recommendations")
}
model UserPresence {
id Int @id @default(autoincrement())
userId String @unique @map("user_id")
isOnline Boolean? @default(false) @map("is_online")
currentMediaId Int? @map("current_media_id")
lastActivityAt DateTime? @map("last_activity_at")
lastVideoChangeAt DateTime? @map("last_video_change_at")
// Relations
user User @relation("UserPresence", fields: [userId], references: [id])
@@index([isOnline], map: "idx_user_presence_online")
@@index([userId], map: "idx_user_presence_user")
@@map("user_presence")
}
model UserGalleryImage {
id Int @id @default(autoincrement())
userId String @map("user_id")
filename String
originalFilename String? @map("original_filename")
position Int @default(0)
uploadedAt DateTime @default(now()) @map("uploaded_at")
// Relations
user User @relation("UserGalleryImages", fields: [userId], references: [id])
@@index([userId], map: "idx_user_gallery_user")
@@index([userId, position], map: "idx_user_gallery_position")
@@map("user_gallery_images")
}
model UserSocialLink {
id Int @id @default(autoincrement())
userId String @map("user_id")
platform SocialPlatform
url String
displayName String? @map("display_name")
position Int @default(0)
createdAt DateTime @default(now()) @map("created_at")
// Relations
user User @relation("UserSocialLinks", fields: [userId], references: [id])
@@index([userId], map: "idx_user_social_links_user")
@@index([userId, position], map: "idx_user_social_links_position")
@@map("user_social_links")
}
model PrivacySettings {
id Int @id @default(autoincrement())
userId String @unique @map("user_id")
showOnlineStatus Boolean? @default(true) @map("show_online_status")
showCurrentlyWatching Boolean? @default(true) @map("show_currently_watching")
showInFriendActivity Boolean? @default(true) @map("show_in_friend_activity")
anonymizePublicComments Boolean? @default(false) @map("anonymize_public_comments")
hidePublicReactions Boolean? @default(false) @map("hide_public_reactions")
hidePublicFinishes Boolean? @default(false) @map("hide_public_finishes")
allowFriendRequests Boolean? @default(true) @map("allow_friend_requests")
closeFriendsOnlyWatching Boolean? @default(false) @map("close_friends_only_watching")
showOnLeaderboard Boolean? @default(true) @map("show_on_leaderboard")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @map("updated_at")
// Relations
user User @relation("PrivacySettings", fields: [userId], references: [id], onDelete: Cascade)
@@index([userId], map: "idx_privacy_settings_user")
@@map("privacy_settings")
}
model CloseFriend {
id Int @id @default(autoincrement())
userId String @map("user_id")
closeFriendId String @map("close_friend_id")
addedAt DateTime @default(now()) @map("added_at")
// Relations
user User @relation("CloseFriends", fields: [userId], references: [id], onDelete: Cascade)
closeFriend User @relation("CloseFriendOf", fields: [closeFriendId], references: [id], onDelete: Cascade)
@@unique([userId, closeFriendId], map: "idx_close_friends_unique")
@@index([userId], map: "idx_close_friends_user")
@@map("close_friends")
}
// ============================================================================
// SOCIAL GROUPS
// ============================================================================
enum SocialGroupType {
SHIFT_TEAM
CAMPAIGN_TEAM
CUSTOM
}
model SocialGroup {
id String @id @default(cuid())
name String
type SocialGroupType
referenceId String? @map("reference_id")
meetingId String? @unique @map("meeting_id")
meeting Meeting? @relation("GroupMeeting", fields: [meetingId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) @map("created_at")
members SocialGroupMember[]
@@unique([type, referenceId], map: "idx_social_groups_type_ref")
@@map("social_groups")
}
model SocialGroupMember {
id String @id @default(cuid())
groupId String @map("group_id")
group SocialGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
userId String @map("user_id")
user User @relation("SocialGroupMember", fields: [userId], references: [id], onDelete: Cascade)
joinedAt DateTime @default(now()) @map("joined_at")
@@unique([groupId, userId], map: "idx_social_group_members_unique")
@@index([userId], map: "idx_social_group_members_user")
@@map("social_group_members")
}
// ============================================================================
// USER UPLOADS
// ============================================================================
model UserUpload {
id Int @id @default(autoincrement())
userId String @map("user_id")
filename String
originalFilename String? @map("original_filename")
path String
durationSeconds Int? @map("duration_seconds")
quality String?
orientation String?
fileSize BigInt? @map("file_size")
thumbnailPath String? @map("thumbnail_path")
status UserUploadStatus @default(pending)
reviewedBy String? @map("reviewed_by")
reviewedAt DateTime? @map("reviewed_at")
reviewNotes String? @map("review_notes")
publicMediaId Int? @map("public_media_id")
uploadInviteId Int? @map("upload_invite_id")
createdAt DateTime @default(now()) @map("created_at")
// Relations
user User @relation("UserUploads", fields: [userId], references: [id])
reviewer User? @relation("UserUploadReviewer", fields: [reviewedBy], references: [id])
uploadInvite UploadInvite? @relation(fields: [uploadInviteId], references: [id])
suggestedTags UserUploadSuggestedTag[]
@@index([userId], map: "idx_user_uploads_user")
@@index([status], map: "idx_user_uploads_status")
@@index([createdAt], map: "idx_user_uploads_created")
@@index([uploadInviteId], map: "idx_user_uploads_invite")
@@map("user_uploads")
}
model UploadInvite {
id Int @id @default(autoincrement())
code String @unique
label String?
createdBy String @map("created_by")
status UploadInviteStatus @default(active)
maxUploads Int? @map("max_uploads")
uploadCount Int @default(0) @map("upload_count")
expiresAt DateTime? @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
// Relations
creator User @relation("UploadInviteCreator", fields: [createdBy], references: [id])
uploads UserUpload[]
@@index([code], map: "idx_upload_invites_code")
@@index([status], map: "idx_upload_invites_status")
@@index([createdBy], map: "idx_upload_invites_created_by")
@@map("upload_invites")
}
// ============================================================================
// TAG SYSTEM
// ============================================================================
model TagCategory {
id Int @id @default(autoincrement())
name String @unique
displayOrder Int @default(0) @map("display_order")
createdAt DateTime @default(now()) @map("created_at")
// Relations
tags Tag[]
@@index([displayOrder], map: "idx_tag_categories_display_order")
@@map("tag_categories")
}
model Tag {
id Int @id @default(autoincrement())
categoryId Int @map("category_id")
name String
displayOrder Int @default(0) @map("display_order")
createdAt DateTime @default(now()) @map("created_at")
// Relations
category TagCategory @relation(fields: [categoryId], references: [id])
publicMedia PublicMediaTag[]
userUploadSuggestions UserUploadSuggestedTag[]
userPreferences UserTagPreference[]
@@index([categoryId], map: "idx_tags_category")
@@index([displayOrder], map: "idx_tags_display_order")
@@index([categoryId, name], map: "idx_tags_unique_name")
@@map("tags")
}
model PublicMediaTag {
id Int @id @default(autoincrement())
mediaId Int @map("media_id")
tagId Int @map("tag_id")
addedAt DateTime @default(now()) @map("added_at")
// Relations
media Video @relation("PublicMediaTags", fields: [mediaId], references: [id])
tag Tag @relation(fields: [tagId], references: [id])
@@index([mediaId], map: "idx_public_media_tags_media")
@@index([tagId], map: "idx_public_media_tags_tag")
@@index([mediaId, tagId], map: "idx_public_media_tags_unique")
@@map("public_media_tags")
}
model UserUploadSuggestedTag {
id Int @id @default(autoincrement())
uploadId Int @map("upload_id")
tagId Int @map("tag_id")
suggestedAt DateTime @default(now()) @map("suggested_at")
// Relations
upload UserUpload @relation(fields: [uploadId], references: [id])
tag Tag @relation(fields: [tagId], references: [id])
@@index([uploadId], map: "idx_user_upload_suggested_tags_upload")
@@index([tagId], map: "idx_user_upload_suggested_tags_tag")
@@index([uploadId, tagId], map: "idx_user_upload_suggested_tags_unique")
@@map("user_upload_suggested_tags")
}
model UserTagPreference {
id Int @id @default(autoincrement())
userId String @map("user_id")
tagId Int @map("tag_id")
savedAt DateTime @default(now()) @map("saved_at")
// Relations
user User @relation("UserTagPreferences", fields: [userId], references: [id])
tag Tag @relation(fields: [tagId], references: [id])
@@index([userId], map: "idx_user_tag_preferences_user")
@@index([tagId], map: "idx_user_tag_preferences_tag")
@@index([userId, tagId], map: "idx_user_tag_preferences_unique")
@@map("user_tag_preferences")
}
model PublicMediaPerformer {
id Int @id @default(autoincrement())
mediaId Int @map("media_id")
performerId Int @map("performer_id")
addedAt DateTime @default(now()) @map("added_at")
// Relations
media Video @relation("PublicMediaPerformers", fields: [mediaId], references: [id])
performer Creator @relation(fields: [performerId], references: [id])
@@unique([mediaId, performerId], map: "idx_public_media_performers_unique")
@@index([mediaId], map: "idx_public_media_performers_media")
@@index([performerId], map: "idx_public_media_performers_performer")
@@map("public_media_performers")
}
// ============================================================================
// VIDEO DIGEST SYSTEM
// ============================================================================
model VideoDigest {
id Int @id @default(autoincrement())
videoId Int @map("video_id")
jobId Int? @unique @map("job_id")
status DigestStatus @default(pending)
progress Int? @default(0)
frameCount Int? @map("frame_count")
config Json?
frameAnalyses Json? @map("frame_analyses")
transcript Json?
tags Json?
suggestedClips Json? @map("suggested_clips")
adCutSpec Json? @map("ad_cut_spec")
stageResults Json? @map("stage_results")
createdAt DateTime @default(now()) @map("created_at")
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
error String?
// Relations
video Video @relation(fields: [videoId], references: [id])
job Job? @relation(fields: [jobId], references: [id])
digestVideoTags DigestVideoTag[]
selectedClips DigestSelectedClip[]
generatedClips DigestGeneratedClip[]
outputFolders DigestOutputFolder[]
compilations DigestCompilation[]
generatedScenes DigestGeneratedScene[]
suggestedTags DigestSuggestedTag[]
@@index([videoId], map: "idx_video_digests_video")
@@index([status], map: "idx_video_digests_status")
@@index([createdAt], map: "idx_video_digests_created")
@@map("video_digests")
}
model DigestVideoTag {
id Int @id @default(autoincrement())
digestId Int @map("digest_id")
videoId Int @map("video_id")
category String
value String
confidence Int?
source String? @default("digest")
evidence Json?
createdAt DateTime @default(now()) @map("created_at")
// Relations
digest VideoDigest @relation(fields: [digestId], references: [id])
video Video @relation(fields: [videoId], references: [id])
@@index([digestId], map: "idx_digest_video_tags_digest")
@@index([videoId], map: "idx_digest_video_tags_video")
@@index([category], map: "idx_digest_video_tags_category")
@@index([value], map: "idx_digest_video_tags_value")
@@index([category, value], map: "idx_digest_video_tags_cat_val")
@@map("digest_video_tags")
}
model DigestSelectedClip {
id Int @id @default(autoincrement())
digestId Int @map("digest_id")
videoId Int @map("video_id")
clipType ClipType @map("clip_type")
startTime Int @map("start_time")
endTime Int @map("end_time")
duration Int
reason String?
interestScore Int? @map("interest_score")
position String?
transcriptHint String? @map("transcript_hint")
tags Json?
source ClipSource @default(machine)
isIncluded Int @default(1) @map("is_included")
isHook Int @default(0) @map("is_hook")
sequenceOrder Int @default(0) @map("sequence_order")
hookSourceClipId Int? @map("hook_source_clip_id")
createdAt DateTime @default(now()) @map("created_at")
// Relations
digest VideoDigest @relation(fields: [digestId], references: [id])
video Video @relation(fields: [videoId], references: [id])
generatedClips DigestGeneratedClip[]
@@index([digestId], map: "idx_digest_selected_clips_digest")
@@index([videoId], map: "idx_digest_selected_clips_video")
@@index([clipType], map: "idx_digest_selected_clips_type")
@@index([source], map: "idx_digest_selected_clips_source")
@@index([sequenceOrder], map: "idx_digest_selected_clips_sequence")
@@index([isHook], map: "idx_digest_selected_clips_is_hook")
@@index([hookSourceClipId], map: "idx_digest_selected_clips_hook_source")
@@map("digest_selected_clips")
}
model DigestGeneratedClip {
id Int @id @default(autoincrement())
selectedClipId Int @map("selected_clip_id")
digestId Int @map("digest_id")
folderId Int? @map("folder_id")
clipPath String? @map("clip_path")
gifPath String? @map("gif_path")
status ClipStatus @default(pending)
error String?
createdAt DateTime @default(now()) @map("created_at")
completedAt DateTime? @map("completed_at")
publishedToPublicMediaId Int? @map("published_to_public_media_id")
publishedAt DateTime? @map("published_at")
// Relations
selectedClip DigestSelectedClip @relation(fields: [selectedClipId], references: [id])
digest VideoDigest @relation(fields: [digestId], references: [id])
folder DigestOutputFolder? @relation(fields: [folderId], references: [id])
@@index([selectedClipId], map: "idx_digest_generated_clips_selected")
@@index([digestId], map: "idx_digest_generated_clips_digest")
@@index([status], map: "idx_digest_generated_clips_status")
@@index([folderId], map: "idx_digest_generated_clips_folder")
@@map("digest_generated_clips")
}
model DigestCompilation {
id Int @id @default(autoincrement())
digestId Int @map("digest_id")
videoId Int @map("video_id")
folderId Int? @map("folder_id")
filename String
name String?
path String
durationSeconds Int? @map("duration_seconds")
orientation String?
status String @default("pending")
error String?
hasCaptions Int @default(0) @map("has_captions")
captionStyle Json? @map("caption_style")
closingAdPath String? @map("closing_ad_path")
closingAdDuration Int? @map("closing_ad_duration")
tags Json?
createdAt DateTime @default(now()) @map("created_at")
completedAt DateTime? @map("completed_at")
// Relations
digest VideoDigest @relation(fields: [digestId], references: [id])
video Video @relation(fields: [videoId], references: [id])
folder DigestOutputFolder? @relation(fields: [folderId], references: [id])
@@index([digestId], map: "idx_digest_compilations_digest")
@@index([videoId], map: "idx_digest_compilations_video")
@@index([status], map: "idx_digest_compilations_status")
@@index([folderId], map: "idx_digest_compilations_folder")
@@map("digest_compilations")
}
model DigestOutputFolder {
id Int @id @default(autoincrement())
digestId Int @map("digest_id")
videoId Int @map("video_id")
folderPath String @map("folder_path")
folderName String @map("folder_name")
folderType String @default("clips") @map("folder_type")
clipCount Int? @default(0) @map("clip_count")
compilationCount Int? @default(0) @map("compilation_count")
totalSize Int? @map("total_size")
createdAt DateTime @default(now()) @map("created_at")
// Relations
digest VideoDigest @relation(fields: [digestId], references: [id])
video Video @relation(fields: [videoId], references: [id])
generatedClips DigestGeneratedClip[]
generatedScenes DigestGeneratedScene[]
compilations DigestCompilation[]
@@index([digestId], map: "idx_digest_output_folders_digest")
@@index([videoId], map: "idx_digest_output_folders_video")
@@index([folderPath], map: "idx_digest_output_folders_path")
@@map("digest_output_folders")
}
model DigestGeneratedScene {
id Int @id @default(autoincrement())
digestId Int @map("digest_id")
videoId Int @map("video_id")
folderId Int? @map("folder_id")
sceneNumber Int @map("scene_number")
scenePath String? @map("scene_path")
startTime Float @map("start_time") @db.Real
endTime Float @map("end_time") @db.Real
duration Float @db.Real
tags Json?
dominantPosition String? @map("dominant_position")
createdAt DateTime @default(now()) @map("created_at")
publishedToPublicMediaId Int? @map("published_to_public_media_id")
publishedAt DateTime? @map("published_at")
// Relations
digest VideoDigest @relation(fields: [digestId], references: [id])
video Video @relation(fields: [videoId], references: [id])
folder DigestOutputFolder? @relation(fields: [folderId], references: [id])
@@index([digestId], map: "idx_digest_generated_scenes_digest")
@@index([videoId], map: "idx_digest_generated_scenes_video")
@@index([folderId], map: "idx_digest_generated_scenes_folder")
@@index([scenePath], map: "idx_digest_generated_scenes_path")
@@map("digest_generated_scenes")
}
model VideoSceneCut {
id Int @id @default(autoincrement())
videoId Int @map("video_id")
cuts Json
sceneCount Int @map("scene_count")
duration Float @db.Real
detector String @default("content")
threshold Float @default(27.0) @db.Real
transnetCuts Json? @map("transnet_cuts")
pyscenedetectCuts Json? @map("pyscenedetect_cuts")
clipCuts Json? @map("clip_cuts")
mergedCuts Json? @map("merged_cuts")
analysisMetadata Json? @map("analysis_metadata")
createdAt DateTime @default(now()) @map("created_at")
// Relations
video Video @relation(fields: [videoId], references: [id])
@@index([videoId], map: "idx_video_scene_cuts_video")
@@index([detector], map: "idx_video_scene_cuts_detector")
@@map("video_scene_cuts")
}
model VideoTagTimeline {
id Int @id @default(autoincrement())
videoId Int @map("video_id")
category String
value String
startTime Float @map("start_time") @db.Real
endTime Float @map("end_time") @db.Real
confidence Int?
source String? @default("digest")
createdAt DateTime @default(now()) @map("created_at")
// Relations
video Video @relation(fields: [videoId], references: [id])
@@index([videoId], map: "idx_video_tag_timeline_video")
@@index([category], map: "idx_video_tag_timeline_category")
@@index([value], map: "idx_video_tag_timeline_value")
@@index([category, value], map: "idx_video_tag_timeline_cat_val")
@@index([videoId, startTime], map: "idx_video_tag_timeline_time")
@@map("video_tag_timeline")
}
model VideoSegment {
id Int @id @default(autoincrement())
videoId Int @map("video_id")
segmentType SegmentType @map("segment_type")
startTime Float @map("start_time") @db.Real
endTime Float @map("end_time") @db.Real
duration Float @db.Real
tags Json?
vocalCategory VocalCategory? @map("vocal_category")
transcript String?
dominantPosition String? @map("dominant_position")
interestScore Int? @map("interest_score")
createdAt DateTime @default(now()) @map("created_at")
// Relations
video Video @relation(fields: [videoId], references: [id])
@@index([videoId], map: "idx_video_segments_video")
@@index([segmentType], map: "idx_video_segments_type")
@@index([vocalCategory], map: "idx_video_segments_vocal_category")
@@index([videoId, startTime], map: "idx_video_segments_time")
@@map("video_segments")
}
model VideoTag {
id Int @id @default(autoincrement())
videoId Int @map("video_id")
category String
value String
confidence Int?
source String? @default("digest")
evidence Json?
createdAt DateTime @default(now()) @map("created_at")
// Relations
video Video @relation(fields: [videoId], references: [id])
@@index([videoId], map: "idx_video_tags_video")
@@index([category], map: "idx_video_tags_category")
@@index([value], map: "idx_video_tags_value")
@@index([category, value], map: "idx_video_tags_cat_val")
@@map("video_tags")
}
model DigestSuggestedTag {
id Int @id @default(autoincrement())
digestId Int @map("digest_id")
category String
value String
confidence Int?
evidence Json?
status SuggestedTagStatus @default(pending)
mappedTagId Int? @map("mapped_tag_id")
createdAt DateTime @default(now()) @map("created_at")
reviewedAt DateTime? @map("reviewed_at")
// Relations
digest VideoDigest @relation(fields: [digestId], references: [id])
@@index([digestId], map: "idx_digest_suggested_tags_digest")
@@index([status], map: "idx_digest_suggested_tags_status")
@@index([category], map: "idx_digest_suggested_tags_category")
@@index([mappedTagId], map: "idx_digest_suggested_tags_mapped")
@@map("digest_suggested_tags")
}
model DigestClipTag {
id Int @id @default(autoincrement())
digestId Int @map("digest_id")
clipId Int @map("clip_id")
videoId Int @map("video_id")
category String
value String
confidence Int?
source String? @default("clip_analysis")
evidence Json?
createdAt DateTime @default(now()) @map("created_at")
// Relations
video Video @relation(fields: [videoId], references: [id])
@@index([digestId], map: "idx_digest_clip_tags_digest")
@@index([clipId], map: "idx_digest_clip_tags_clip")
@@index([videoId], map: "idx_digest_clip_tags_video")
@@index([category], map: "idx_digest_clip_tags_category")
@@index([value], map: "idx_digest_clip_tags_value")
@@map("digest_clip_tags")
}
// ============================================================================
// WATCH PARTY SYSTEM
// ============================================================================
model WatchPartySession {
id Int @id @default(autoincrement())
hostUserId String @map("host_user_id")
mediaId Int @map("media_id")
status WatchPartyStatus @default(active)
inviteCode String @unique @map("invite_code")
currentTime Int @default(0) @map("current_time")
isPlaying Boolean @default(false) @map("is_playing")
createdAt DateTime @default(now()) @map("created_at")
endedAt DateTime? @map("ended_at")
// Relations
host User @relation("WatchPartyHost", fields: [hostUserId], references: [id])
participants WatchPartyParticipant[]
messages WatchPartyChatMessage[]
reactions WatchPartyReaction[]
invites WatchPartyInvite[]
@@index([hostUserId], map: "idx_watch_party_sessions_host")
@@index([mediaId], map: "idx_watch_party_sessions_media")
@@index([status], map: "idx_watch_party_sessions_status")
@@index([inviteCode], map: "idx_watch_party_sessions_invite")
@@map("watch_party_sessions")
}
model WatchPartyParticipant {
id Int @id @default(autoincrement())
sessionId Int @map("session_id")
userId String @map("user_id")
joinedAt DateTime @default(now()) @map("joined_at")
leftAt DateTime? @map("left_at")
// Relations
session WatchPartySession @relation(fields: [sessionId], references: [id])
user User @relation("WatchPartyParticipant", fields: [userId], references: [id])
@@index([sessionId], map: "idx_watch_party_participants_session")
@@index([userId], map: "idx_watch_party_participants_user")
@@map("watch_party_participants")
}
model WatchPartyChatMessage {
id Int @id @default(autoincrement())
sessionId Int @map("session_id")
userId String @map("user_id")
commentId Int @map("comment_id")
createdAt DateTime @default(now()) @map("created_at")
// Relations
session WatchPartySession @relation(fields: [sessionId], references: [id])
user User @relation("WatchPartyChatUser", fields: [userId], references: [id])
comment Comment @relation(fields: [commentId], references: [id])
@@index([sessionId], map: "idx_watch_party_chat_session")
@@index([userId], map: "idx_watch_party_chat_user")
@@map("watch_party_chat_messages")
}
model WatchPartyReaction {
id Int @id @default(autoincrement())
sessionId Int @map("session_id")
userId String @map("user_id")
reactionType ReactionType @map("reaction_type")
videoTimestamp Int @map("video_timestamp")
createdAt DateTime @default(now()) @map("created_at")
// Relations
session WatchPartySession @relation(fields: [sessionId], references: [id])
user User @relation("WatchPartyReactionUser", fields: [userId], references: [id])
@@index([sessionId], map: "idx_watch_party_reactions_session")
@@index([userId], map: "idx_watch_party_reactions_user")
@@map("watch_party_reactions")
}
model WatchPartyInvite {
id Int @id @default(autoincrement())
sessionId Int @map("session_id")
userId String @map("user_id")
invitedBy String @map("invited_by")
status String @default("pending")
createdAt DateTime @default(now()) @map("created_at")
acceptedAt DateTime? @map("accepted_at")
// Relations
session WatchPartySession @relation(fields: [sessionId], references: [id])
user User @relation("WatchPartyInvitee", fields: [userId], references: [id])
inviter User @relation("WatchPartyInviter", fields: [invitedBy], references: [id])
@@index([sessionId], map: "idx_watch_party_invites_session")
@@index([userId], map: "idx_watch_party_invites_user")
@@map("watch_party_invites")
}
// ============================================================================
// TAG GENERATION JOBS
// ============================================================================
model TagGenerationJob {
id Int @id @default(autoincrement())
videoId Int @map("video_id")
jobId Int? @map("job_id")
status String @default("pending")
progress Int? @default(0)
generatedTags Json? @map("generated_tags")
rawResponse String? @map("raw_response")
error String?
createdAt DateTime @default(now()) @map("created_at")
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
// Relations
video Video @relation(fields: [videoId], references: [id])
job Job? @relation(fields: [jobId], references: [id])
@@index([videoId], map: "idx_tag_generation_jobs_video")
@@index([status], map: "idx_tag_generation_jobs_status")
@@map("tag_generation_jobs")
}
// ============================================================================
// CREATORS & PERFORMERS
// ============================================================================
model Creator {
id Int @id @default(autoincrement())
name String @unique
stage_name String? @map("stage_name")
performerGender String? @map("performer_gender")
faceEmbedding Json? @map("face_embedding")
referenceImagePath String? @map("reference_image_path")
performerCategory String? @map("performer_category")
status String @default("active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @map("updated_at")
// Relations
publicMediaPerformers PublicMediaPerformer[]
performerFaces PerformerFace[]
performerDiscrepancies PerformerDiscrepancy[]
@@index([name], map: "idx_creators_name")
@@index([performerCategory], map: "idx_creators_category")
@@map("creators")
}
model PerformerFace {
id Int @id @default(autoincrement())
performerId Int @map("performer_id")
sourcePath String @map("source_path")
frameTimestamp Float? @map("frame_timestamp") @db.Real
bbox Json
embedding Json
gender Int
age Int
detScore Float @map("det_score") @db.Real
verifiedByUser Boolean @default(false) @map("verified_by_user")
confidence Float? @db.Real
isReferenceImage Boolean @default(false) @map("is_reference_image")
createdAt DateTime @default(now()) @map("created_at")
// Relations
performer Creator @relation(fields: [performerId], references: [id])
@@index([performerId], map: "idx_performer_faces_performer")
@@index([sourcePath], map: "idx_performer_faces_source")
@@index([isReferenceImage], map: "idx_performer_faces_reference")
@@map("performer_faces")
}
model PerformerDiscrepancy {
id Int @id @default(autoincrement())
videoPath String @map("video_path")
frameTimestamp Float @map("frame_timestamp") @db.Real
assignedPerformerId Int @map("assigned_performer_id")
detectedPerformerId Int? @map("detected_performer_id")
similarity Float @map("similarity") @db.Real
faceData Json @map("face_data")
resolutionStatus String @default("pending") @map("resolution_status")
resolutionAction String? @map("resolution_action")
resolutionNotes String? @map("resolution_notes")
resolvedBy String? @map("resolved_by")
resolvedAt DateTime? @map("resolved_at")
createdAt DateTime @default(now()) @map("created_at")
// Relations
assignedPerformer Creator @relation(fields: [assignedPerformerId], references: [id])
resolver User? @relation("PerformerDiscrepancyResolver", fields: [resolvedBy], references: [id])
@@index([assignedPerformerId], map: "idx_performer_discrepancies_assigned")
@@index([detectedPerformerId], map: "idx_performer_discrepancies_detected")
@@index([resolutionStatus], map: "idx_performer_discrepancies_status")
@@map("performer_discrepancies")
}
// ============================================================================
// PIPELINE SYSTEM
// ============================================================================
model Pipeline {
id Int @id @default(autoincrement())
name String
description String?
status PipelineStatus @default(draft)
config Json?
templateId Int? @map("template_id")
createdAt DateTime @default(now()) @map("created_at")
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
error String?
// Relations
template PipelineTemplate? @relation(fields: [templateId], references: [id])
steps PipelineStep[]
jobs Job[]
@@index([status], map: "idx_pipelines_status")
@@index([templateId], map: "idx_pipelines_template")
@@map("pipelines")
}
model PipelineStep {
id Int @id @default(autoincrement())
pipelineId Int @map("pipeline_id")
stepName String @map("step_name")
stepType String @map("step_type")
sequenceOrder Int @map("sequence_order")
status PipelineStepStatus @default(pending)
config Json?
inputs Json?
outputs Json?
dependsOn Json? @map("depends_on")
error String?
createdAt DateTime @default(now()) @map("created_at")
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
// Relations
pipeline Pipeline @relation(fields: [pipelineId], references: [id])
jobs Job[]
events PipelineStepEvent[]
@@index([pipelineId], map: "idx_pipeline_steps_pipeline")
@@index([status], map: "idx_pipeline_steps_status")
@@index([sequenceOrder], map: "idx_pipeline_steps_sequence")
@@map("pipeline_steps")
}
model PipelineStepEvent {
id Int @id @default(autoincrement())
stepId Int @map("step_id")
eventType String @map("event_type")
message String?
data Json?
createdAt DateTime @default(now()) @map("created_at")
// Relations
step PipelineStep @relation(fields: [stepId], references: [id])
@@index([stepId], map: "idx_pipeline_step_events_step")
@@index([eventType], map: "idx_pipeline_step_events_type")
@@map("pipeline_step_events")
}
model ResourceSnapshot {
id Int @id @default(autoincrement())
timestamp DateTime @default(now())
cpuPercent Float? @map("cpu_percent") @db.Real
memoryMb Int? @map("memory_mb")
gpuPercent Float? @map("gpu_percent") @db.Real
vramMb Int? @map("vram_mb")
diskUsageMb Int? @map("disk_usage_mb")
activeJobs Int? @map("active_jobs")
queuedJobs Int? @map("queued_jobs")
metadata Json?
@@index([timestamp], map: "idx_resource_snapshots_timestamp")
@@map("resource_snapshots")
}
model PipelineTemplate {
id Int @id @default(autoincrement())
name String
description String?
category String?
defaultConfig Json? @map("default_config")
stepDefinitions Json @map("step_definitions")
isPublic Boolean @default(false) @map("is_public")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @map("updated_at")
// Relations
pipelines Pipeline[]
@@index([category], map: "idx_pipeline_templates_category")
@@index([isPublic], map: "idx_pipeline_templates_public")
@@map("pipeline_templates")
}
// ============================================================================
// SUBSCRIPTION & PAYMENTS
// ============================================================================
model SubscriptionPlan {
id Int @id @default(autoincrement())
name String
priceCAD Int @map("price_cad")
durationDays Int @map("duration_days")
features Json?
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
// Stripe integration
stripeProductId String? @map("stripe_product_id")
stripePriceId String? @map("stripe_price_id")
stripeYearlyPriceId String? @map("stripe_yearly_price_id")
yearlyPriceCAD Int? @map("yearly_price_cad")
description String? @db.Text
tier Int @default(0)
displayOrder Int @default(0) @map("display_order")
// Page content fields
slug String? @unique
coverPhoto String? @map("cover_photo")
coverVideoId Int? @map("cover_video_id")
richDescription String? @db.Text @map("rich_description")
ctaText String? @map("cta_text")
ctaSubtext String? @map("cta_subtext")
highlightPlan Boolean @default(false) @map("highlight_plan")
// Relations
subscriptions UserSubscription[]
@@map("subscription_plans")
}
model UserSubscription {
id Int @id @default(autoincrement())
userId String @map("user_id")
planId Int @map("plan_id")
status SubscriptionStatus @default(active)
startDate DateTime @map("start_date")
endDate DateTime @map("end_date")
cancelledAt DateTime? @map("cancelled_at")
createdAt DateTime @default(now()) @map("created_at")
// Stripe integration
stripeSubscriptionId String? @unique @map("stripe_subscription_id")
stripeCustomerId String? @map("stripe_customer_id")
currentPeriodEnd DateTime? @map("current_period_end")
cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end")
// Relations
user User @relation("UserSubscriptions", fields: [userId], references: [id])
plan SubscriptionPlan @relation(fields: [planId], references: [id])
@@index([userId], map: "idx_user_subscriptions_user")
@@index([planId], map: "idx_user_subscriptions_plan")
@@index([status], map: "idx_user_subscriptions_status")
@@map("user_subscriptions")
}
model Invoice {
id Int @id @default(autoincrement())
userId String @map("user_id")
subscriptionId Int? @map("subscription_id")
amountCAD Int @map("amount_cad")
status InvoiceStatus @default(pending)
issuedAt DateTime @default(now()) @map("issued_at")
paidAt DateTime? @map("paid_at")
dueDate DateTime @map("due_date")
description String?
metadata Json?
// Stripe integration
stripeInvoiceId String? @unique @map("stripe_invoice_id")
type String @default("subscription") @map("invoice_type") // subscription|product|donation
// Relations
user User @relation("UserInvoices", fields: [userId], references: [id])
payments Payment[]
@@index([userId], map: "idx_invoices_user")
@@index([status], map: "idx_invoices_status")
@@index([issuedAt], map: "idx_invoices_issued")
@@map("invoices")
}
model Payment {
id Int @id @default(autoincrement())
invoiceId Int @map("invoice_id")
userId String @map("user_id")
amountCAD Int @map("amount_cad")
method PaymentMethod
status PaymentStatus @default(pending)
externalId String? @map("external_id")
metadata Json?
processedAt DateTime? @map("processed_at")
createdAt DateTime @default(now()) @map("created_at")
// Stripe integration
stripePaymentIntentId String? @unique @map("stripe_payment_intent_id")
stripeCheckoutSessionId String? @map("stripe_checkout_session_id")
// Relations
invoice Invoice @relation(fields: [invoiceId], references: [id])
user User @relation("UserPayments", fields: [userId], references: [id])
auditLogs PaymentAuditLog[]
@@index([invoiceId], map: "idx_payments_invoice")
@@index([userId], map: "idx_payments_user")
@@index([status], map: "idx_payments_status")
@@map("payments")
}
model PaymentAuditLog {
id Int @id @default(autoincrement())
paymentId Int @map("payment_id")
action String
oldStatus String? @map("old_status")
newStatus String? @map("new_status")
userId String? @map("user_id")
metadata Json?
createdAt DateTime @default(now()) @map("created_at")
// Relations
payment Payment @relation(fields: [paymentId], references: [id])
user User? @relation("PaymentAuditUser", fields: [userId], references: [id])
@@index([paymentId], map: "idx_payment_audit_log_payment")
@@index([action], map: "idx_payment_audit_log_action")
@@index([createdAt], map: "idx_payment_audit_log_created")
@@map("payment_audit_log")
}
model Product {
id String @id @default(cuid())
slug String @unique
title String
description String? @db.Text
priceCAD Int @map("price_cad")
type ProductType
stripeProductId String? @map("stripe_product_id")
stripePriceId String? @map("stripe_price_id")
isActive Boolean @default(true) @map("is_active")
imageUrl String? @map("image_url")
photoId Int? @map("photo_id")
videoId Int? @map("video_id")
galleryPhotoIds Json? @map("gallery_photo_ids")
downloadUrl String? @map("download_url")
metadata Json?
maxPurchases Int? @map("max_purchases")
purchaseCount Int @default(0) @map("purchase_count")
createdByUserId String? @map("created_by_user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
orders Order[]
galleryAd Ad?
@@index([type], map: "idx_products_type")
@@index([isActive], map: "idx_products_active")
@@map("products")
}
model Order {
id String @id @default(cuid())
userId String? @map("user_id")
productId String? @map("product_id")
amountCAD Int @map("amount_cad")
status OrderStatus @default(PENDING)
stripeCheckoutSessionId String? @unique @map("stripe_checkout_session_id")
stripePaymentIntentId String? @map("stripe_payment_intent_id")
type String @default("product") @map("order_type") // product|donation
// Buyer info (for guests)
buyerEmail String @map("buyer_email")
buyerName String? @map("buyer_name")
// Donation-specific
donorMessage String? @db.Text @map("donor_message")
isAnonymous Boolean @default(false) @map("is_anonymous")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User? @relation("UserOrders", fields: [userId], references: [id])
product Product? @relation(fields: [productId], references: [id])
donationPageId String? @map("donation_page_id")
donationPage DonationPage? @relation("DonationPageOrders", fields: [donationPageId], references: [id], onDelete: SetNull)
influenceCampaignId String? @map("influence_campaign_id")
influenceCampaign Campaign? @relation("CampaignDonations", fields: [influenceCampaignId], references: [id], onDelete: SetNull)
tickets Ticket[] @relation("TicketOrder")
@@index([userId], map: "idx_orders_user")
@@index([productId], map: "idx_orders_product")
@@index([status], map: "idx_orders_status")
@@index([type], map: "idx_orders_type")
@@index([donationPageId], map: "idx_orders_donation_page")
@@index([influenceCampaignId], map: "idx_orders_influence_campaign")
@@map("orders")
}
enum DonationPageStatus {
DRAFT
ACTIVE
PAUSED
ARCHIVED
}
model DonationPage {
id String @id @default(cuid())
slug String @unique
title String
description String? @db.Text
status DonationPageStatus @default(DRAFT)
// Donation config (per-page, mirrors PaymentSettings fields)
suggestedAmounts Json @default("[1000, 2500, 5000, 10000]")
minimumAmount Int @default(500) @map("minimum_amount")
thankYouMessage String @default("Thank you for your support!") @db.Text @map("thank_you_message")
// Media
coverPhoto String? @map("cover_photo")
coverVideoId Int? @map("cover_video_id")
// Display options
highlightPage Boolean @default(false) @map("highlight_page")
showDonorCount Boolean @default(true) @map("show_donor_count")
showTotalRaised Boolean @default(false) @map("show_total_raised")
goalAmount Int? @map("goal_amount")
// Creator tracking
createdByUserId String? @map("created_by_user_id")
createdByUser User? @relation("DonationPageCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
orders Order[] @relation("DonationPageOrders")
@@index([status])
@@map("donation_pages")
}
model PaymentSettings {
id String @id @default(cuid())
stripeSecretKey String @default("") @map("stripe_secret_key")
stripePublishableKey String @default("") @map("stripe_publishable_key")
stripeWebhookSecret String @default("") @map("stripe_webhook_secret")
defaultCurrency String @default("cad") @map("default_currency")
// Donation settings
enableDonations Boolean @default(true) @map("enable_donations")
donationSuggestedAmounts Json @default("[1000, 2500, 5000, 10000]") @map("donation_suggested_amounts")
donationMinimum Int @default(500) @map("donation_minimum")
donationPageTitle String @default("Support Our Work") @map("donation_page_title")
donationPageDescription String? @db.Text @map("donation_page_description")
thankYouMessage String @default("Thank you for your support!") @db.Text @map("thank_you_message")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("payment_settings")
}
// ============================================================================
// NOTIFICATIONS
// ============================================================================
model Notification {
id Int @id @default(autoincrement())
userId String @map("user_id")
type NotificationType
title String
message String
metadata Json?
isRead Boolean @default(false) @map("is_read")
createdAt DateTime @default(now()) @map("created_at")
readAt DateTime? @map("read_at")
// Relations
user User @relation("UserNotifications", fields: [userId], references: [id])
@@index([userId], map: "idx_notifications_user")
@@index([isRead], map: "idx_notifications_read")
@@index([type], map: "idx_notifications_type")
@@index([createdAt], map: "idx_notifications_created")
@@map("notifications")
}
model NotificationPreferences {
id Int @id @default(autoincrement())
userId String @unique @map("user_id")
enableFriendRequests Boolean @default(true) @map("enable_friend_requests")
enableComments Boolean @default(true) @map("enable_comments")
enableUploadApprovals Boolean @default(true) @map("enable_upload_approvals")
enableAchievements Boolean @default(true) @map("enable_achievements")
enableSystemUpdates Boolean @default(true) @map("enable_system_updates")
emailNotifications Boolean @default(false) @map("email_notifications")
digestFrequency String @default("none") @map("digest_frequency") // "none" | "daily" | "weekly"
lastDigestSentAt DateTime? @map("last_digest_sent_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @map("updated_at")
// Relations
user User @relation("NotificationPreferences", fields: [userId], references: [id])
@@index([userId], map: "idx_notification_preferences_user")
@@map("notification_preferences")
}
// ============================================================================
// GEO-BLOCKING
// ============================================================================
model GeoBlockingRule {
id Int @id @default(autoincrement())
countryCode String @map("country_code")
action String // 'block' | 'allow'
reason String?
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @map("updated_at")
@@index([countryCode], map: "idx_geo_blocking_country")
@@index([isActive], map: "idx_geo_blocking_active")
@@map("geo_blocking_rules")
}
// ============================================================================
// PUBLISHED INBOX FILES
// ============================================================================
model PublishedInboxFile {
id Int @id @default(autoincrement())
originalInboxPath String @map("original_inbox_path")
publishedToPublicMediaId Int @map("published_to_public_media_id")
publishedAt DateTime @map("published_at")
movedToPath String? @map("moved_to_path")
metadataSnapshot Json? @map("metadata_snapshot")
@@index([originalInboxPath], map: "idx_published_inbox_files_inbox_path")
@@index([publishedToPublicMediaId], map: "idx_published_inbox_files_public_media")
@@map("published_inbox_files")
}
// ============================================================================
// VIDEO OCR RESULTS
// ============================================================================
model VideoOcrResult {
id Int @id @default(autoincrement())
videoId Int @map("video_id")
fullText String @map("full_text")
structuredData Json? @map("structured_data")
confidence Float? @db.Real
createdAt DateTime @default(now()) @map("created_at")
// Relations
video Video @relation(fields: [videoId], references: [id])
@@index([videoId], map: "idx_video_ocr_results_video")
@@map("video_ocr_results")
}
// Enhanced video analytics models
model VideoView {
id Int @id @default(autoincrement())
videoId Int @map("video_id")
userId String? @map("user_id")
ipAddressHash String? @map("ip_address_hash") @db.VarChar(64) // SHA-256 hash
userAgentHash String? @map("user_agent_hash") @db.VarChar(64) // SHA-256 hash
referer String? @db.Text
watchTimeSeconds Int @default(0) @map("watch_time_seconds")
completed Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
user User? @relation("VideoViews", fields: [userId], references: [id], onDelete: SetNull)
@@index([videoId], map: "idx_video_views_video")
@@index([userId], map: "idx_video_views_user")
@@index([createdAt], map: "idx_video_views_created")
@@index([videoId, createdAt], map: "idx_video_views_video_created")
@@map("video_views")
}
model VideoEvent {
id Int @id @default(autoincrement())
videoId Int @map("video_id")
viewId Int? @map("view_id")
eventType String @map("event_type") @db.VarChar(50) // play, pause, seek, complete
timestamp Decimal @db.Decimal(10, 2) // Video timestamp in seconds
createdAt DateTime @default(now()) @map("created_at")
// Relations
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
@@index([videoId], map: "idx_video_events_video")
@@index([viewId], map: "idx_video_events_view")
@@index([eventType], map: "idx_video_events_type")
@@index([createdAt], map: "idx_video_events_created")
@@map("video_events")
}
model VideoScheduleHistory {
id Int @id @default(autoincrement())
videoId Int @map("video_id")
action String @db.VarChar(20) // 'publish' or 'unpublish'
scheduledFor DateTime @map("scheduled_for")
executedAt DateTime? @map("executed_at")
status String @db.VarChar(20) // 'pending', 'completed', 'failed', 'cancelled'
error String? @db.Text
scheduledByUserId String @map("scheduled_by_user_id")
createdAt DateTime @default(now()) @map("created_at")
// Relations
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
scheduledBy User @relation("VideoScheduleHistory", fields: [scheduledByUserId], references: [id])
@@index([videoId], map: "idx_video_schedule_history_video")
@@index([scheduledFor], map: "idx_video_schedule_history_scheduled")
@@index([status], map: "idx_video_schedule_history_status")
@@index([scheduledByUserId], map: "idx_video_schedule_history_user")
@@map("video_schedule_history")
}
// ============================================================================
// DOCS ANALYTICS
// ============================================================================
model DocsPageView {
id String @id @default(cuid())
path String // e.g. "/docs/getting-started/"
referrer String? @db.Text // document.referrer
sessionHash String? // anonymous session UUID (sessionStorage)
userAgent String? // for device type breakdown
createdAt DateTime @default(now())
@@index([createdAt])
@@index([path, createdAt])
@@map("docs_page_views")
}
// ============================================================================
// PHOTO GALLERY
// ============================================================================
model Photo {
id Int @id @default(autoincrement())
path String @unique // Full path to original file
filename String // UUID filename on disk
originalFilename String? @map("original_filename") // Original upload filename
title String?
description String? @db.Text
producer String?
creator String?
tags Json? // String array
// Image metadata (from sharp)
width Int?
height Int?
orientation String? // H / V / S (horizontal/vertical/square)
fileSize BigInt? @map("file_size")
format String? // jpeg, png, webp, avif, gif, tiff, heic
colorSpace String? @map("color_space") // srgb, display-p3, etc.
hasAlpha Boolean? @default(false) @map("has_alpha")
dpi Int?
// EXIF data
cameraMake String? @map("camera_make")
cameraModel String? @map("camera_model")
focalLength String? @map("focal_length")
aperture String?
shutterSpeed String? @map("shutter_speed")
iso Int?
takenAt DateTime? @map("taken_at")
gpsLatitude Float? @map("gps_latitude") @db.Real
gpsLongitude Float? @map("gps_longitude") @db.Real
// Processed variants
thumbnailPath String? @map("thumbnail_path")
mediumPath String? @map("medium_path")
largePath String? @map("large_path")
webpPath String? @map("webp_path")
// Publishing (mirrors Video)
isPublished Boolean @default(false) @map("is_published")
publishedAt DateTime? @map("published_at")
category String?
accessLevel String @default("free") @map("access_level")
position Int? @default(0)
isLocked Boolean @default(false) @map("is_locked")
scheduledPublishAt DateTime? @map("scheduled_publish_at")
scheduledUnpublishAt DateTime? @map("scheduled_unpublish_at")
// Engagement counters
viewCount Int @default(0) @map("view_count")
upvoteCount Int @default(0) @map("upvote_count")
commentCount Int @default(0) @map("comment_count")
// Album membership
albumId Int? @map("album_id")
albumPosition Int? @default(0) @map("album_position")
// Tracking
uploaderId String? @map("uploader_id")
createdAt DateTime @default(now()) @map("created_at")
// Relations
album PhotoAlbum? @relation("AlbumPhotos", fields: [albumId], references: [id], onDelete: SetNull)
uploader User? @relation("PhotoUploader", fields: [uploaderId], references: [id])
upvotes PhotoUpvote[]
comments PhotoComment[]
views PhotoView[]
reactions PhotoReaction[]
coverForAlbum PhotoAlbum? @relation("AlbumCover")
@@index([orientation], map: "idx_photos_orientation")
@@index([producer], map: "idx_photos_producer")
@@index([isPublished, isLocked], map: "idx_photos_published_locked")
@@index([category, isPublished], map: "idx_photos_category_published")
@@index([albumId, albumPosition], map: "idx_photos_album_position")
@@index([createdAt], map: "idx_photos_created_at")
@@index([uploaderId], map: "idx_photos_uploader")
@@map("photos")
}
model PhotoAlbum {
id Int @id @default(autoincrement())
title String
description String? @db.Text
coverPhotoId Int? @unique @map("cover_photo_id")
// Publishing
isPublished Boolean @default(false) @map("is_published")
publishedAt DateTime? @map("published_at")
category String?
accessLevel String @default("free") @map("access_level")
position Int? @default(0)
isLocked Boolean @default(false) @map("is_locked")
// Aggregate counters
viewCount Int @default(0) @map("view_count")
upvoteCount Int @default(0) @map("upvote_count")
photoCount Int @default(0) @map("photo_count")
// Scheduling
scheduledPublishAt DateTime? @map("scheduled_publish_at")
scheduledUnpublishAt DateTime? @map("scheduled_unpublish_at")
// Tracking
creatorId String? @map("creator_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
photos Photo[] @relation("AlbumPhotos")
coverPhoto Photo? @relation("AlbumCover", fields: [coverPhotoId], references: [id], onDelete: SetNull)
creator User? @relation("AlbumCreator", fields: [creatorId], references: [id])
@@index([isPublished], map: "idx_photo_albums_published")
@@index([creatorId], map: "idx_photo_albums_creator")
@@map("photo_albums")
}
model PhotoUpvote {
id Int @id @default(autoincrement())
photoId Int @map("photo_id")
sessionId String @map("session_id")
createdAt DateTime @default(now()) @map("created_at")
photo Photo @relation(fields: [photoId], references: [id], onDelete: Cascade)
session Session @relation("SessionPhotoUpvotes", fields: [sessionId], references: [id])
@@unique([photoId, sessionId], map: "idx_photo_upvotes_unique")
@@index([photoId], map: "idx_photo_upvotes_photo")
@@map("photo_upvotes")
}
model PhotoComment {
id Int @id @default(autoincrement())
photoId Int @map("photo_id")
sessionId String @map("session_id")
userId String? @map("user_id")
content String @db.Text
createdAt DateTime @default(now()) @map("created_at")
safetyStatus String @default("approved") @map("safety_status")
isHidden Boolean @default(false) @map("is_hidden")
photo Photo @relation(fields: [photoId], references: [id], onDelete: Cascade)
session Session @relation("SessionPhotoComments", fields: [sessionId], references: [id])
user User? @relation("PhotoCommentUser", fields: [userId], references: [id])
@@index([photoId, createdAt], map: "idx_photo_comments_photo_date")
@@index([sessionId], map: "idx_photo_comments_session")
@@map("photo_comments")
}
model PhotoView {
id Int @id @default(autoincrement())
photoId Int @map("photo_id")
sessionId String? @map("session_id")
userId String? @map("user_id")
ipAddressHash String? @map("ip_address_hash")
viewedAt DateTime @default(now()) @map("viewed_at")
photo Photo @relation(fields: [photoId], references: [id], onDelete: Cascade)
@@index([photoId, viewedAt], map: "idx_photo_views_photo_date")
@@index([sessionId], map: "idx_photo_views_session")
@@map("photo_views")
}
model PhotoReaction {
id Int @id @default(autoincrement())
photoId Int @map("photo_id")
sessionId String @map("session_id")
reactionType String @map("reaction_type") // like, love, laugh, wow, sad, angry
createdAt DateTime @default(now()) @map("created_at")
photo Photo @relation(fields: [photoId], references: [id], onDelete: Cascade)
session Session @relation("SessionPhotoReactions", fields: [sessionId], references: [id])
@@unique([photoId, sessionId, reactionType], map: "idx_photo_reactions_unique")
@@index([photoId], map: "idx_photo_reactions_photo")
@@map("photo_reactions")
}
// ============================================================================
// DOCS COMMENTS (Gitea Issues-backed)
// ============================================================================
enum DocsCommentStatus {
PENDING
APPROVED
REJECTED
}
model DocsComment {
id String @id @default(cuid())
pagePath String
giteaIssueNumber Int
giteaCommentId BigInt
authorName String
authorEmail String?
status DocsCommentStatus @default(PENDING)
reviewedAt DateTime?
reviewedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([pagePath, status])
@@index([status, createdAt])
@@index([giteaCommentId])
@@map("docs_comments")
}
// ============================================================================
// SMS CAMPAIGNS
// ============================================================================
enum SmsContactListStatus {
ACTIVE
ARCHIVED
}
enum SmsCampaignStatus {
DRAFT
RUNNING
PAUSED
COMPLETED
FAILED
}
enum SmsMessageDirection {
OUTBOUND
INBOUND
}
enum SmsMessageStatus {
PENDING
SENT
FAILED
DELIVERED
}
enum SmsResponseType {
POSITIVE
NEGATIVE
QUESTION
OPT_OUT
NEUTRAL
}
enum SmsConversationStatus {
ACTIVE
OPTED_OUT
CLOSED
}
model SmsContactList {
id String @id @default(cuid())
name String
originalFilename String?
totalContacts Int @default(0)
status SmsContactListStatus @default(ACTIVE)
createdByUserId String?
createdByUser User? @relation("SmsContactListCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
entries SmsContactListEntry[]
campaigns SmsCampaign[]
@@map("sms_contact_lists")
}
model SmsContactListEntry {
id String @id @default(cuid())
listId String
list SmsContactList @relation(fields: [listId], references: [id], onDelete: Cascade)
phone String
name String?
email String?
customFields Json? // Arbitrary key-value pairs from CSV columns
createdAt DateTime @default(now())
@@unique([listId, phone])
@@index([listId])
@@index([phone])
@@map("sms_contact_list_entries")
}
model SmsCampaign {
id String @id @default(cuid())
name String
messageTemplate String @db.Text
status SmsCampaignStatus @default(DRAFT)
totalRecipients Int @default(0)
totalSent Int @default(0)
totalFailed Int @default(0)
totalResponded Int @default(0)
delayBetweenMs Int @default(3000)
startedAt DateTime?
completedAt DateTime?
// Relations
contactListId String
contactList SmsContactList @relation(fields: [contactListId], references: [id])
advocacyCampaignId String?
advocacyCampaign Campaign? @relation("SmsCampaigns", fields: [advocacyCampaignId], references: [id], onDelete: SetNull)
createdByUserId String?
createdByUser User? @relation("SmsCampaignCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
recipients SmsCampaignRecipient[]
messages SmsMessage[]
conversations SmsConversation[]
@@index([status])
@@index([contactListId])
@@index([advocacyCampaignId])
@@map("sms_campaigns")
}
model SmsCampaignRecipient {
id String @id @default(cuid())
campaignId String
campaign SmsCampaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
phone String
name String?
status SmsMessageStatus @default(PENDING)
sentAt DateTime?
errorMessage String?
createdAt DateTime @default(now())
@@index([campaignId, status])
@@index([phone])
@@map("sms_campaign_recipients")
}
model SmsMessage {
id String @id @default(cuid())
phone String
message String @db.Text
direction SmsMessageDirection
status SmsMessageStatus @default(PENDING)
connectionType String? // e.g. "termux"
// Campaign context (nullable for ad-hoc messages)
campaignId String?
campaign SmsCampaign? @relation(fields: [campaignId], references: [id], onDelete: SetNull)
conversationId String?
conversation SmsConversation? @relation(fields: [conversationId], references: [id], onDelete: SetNull)
// Response classification (for inbound messages)
responseType SmsResponseType?
isRead Boolean @default(false)
sentAt DateTime @default(now())
@@index([phone])
@@index([campaignId])
@@index([conversationId])
@@index([direction, sentAt])
@@map("sms_messages")
}
model SmsConversation {
id String @id @default(cuid())
phone String
contactName String?
campaignId String?
campaign SmsCampaign? @relation(fields: [campaignId], references: [id], onDelete: SetNull)
contactId String?
contact Contact? @relation("ContactSmsConversations", fields: [contactId], references: [id], onDelete: SetNull)
status SmsConversationStatus @default(ACTIVE)
totalMessages Int @default(0)
totalResponses Int @default(0)
unreadCount Int @default(0)
lastMessageAt DateTime?
lastResponseAt DateTime?
notes String? @db.Text
tags Json? // String array
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages SmsMessage[]
@@unique([phone, campaignId])
@@index([status])
@@index([lastMessageAt])
@@index([contactId])
@@map("sms_conversations")
}
model SmsMessageTemplate {
id String @id @default(cuid())
name String
template String @db.Text
description String?
category String?
isFavorite Boolean @default(false)
usageCount Int @default(0)
createdByUserId String?
createdByUser User? @relation("SmsTemplateCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("sms_message_templates")
}
model SmsDeviceStatus {
id String @id @default(cuid())
isConnected Boolean @default(false)
connectionType String?
batteryLevel Int?
batteryStatus String?
totalSent Int @default(0)
lastCheckedAt DateTime @default(now())
@@map("sms_device_status")
}
// ============================================================================
// PEOPLE CRM
// ============================================================================
enum ContactSource {
USER
ADDRESS_OCCUPANT
CAMPAIGN_SENDER
SHIFT_SIGNUP
SMS_CONTACT
DONATION
POLL_VOTE
MANUAL
}
enum ConnectionType {
HOUSEHOLD
FAMILY
COLLEAGUE
REFERRED_BY
CUSTOM
}
enum ContactActivityType {
EMAIL_SENT
RESPONSE_SUBMITTED
SHIFT_SIGNUP
CANVASS_VISIT
DONATION
PURCHASE
SMS_SENT
SMS_RECEIVED
VIDEO_VIEW
NOTE_ADDED
CONTACT_MERGED
PROFILE_SELF_EDIT
PROFILE_PHOTO_UPDATED
USER_LOGIN
POLL_VOTED
}
model Contact {
id String @id @default(cuid())
displayName String
firstName String?
lastName String?
email String?
phone String?
pronouns String?
// CRM data
tags Json @default("[]") // String array
notes String? @db.Text
supportLevel SupportLevel?
signRequested Boolean @default(false)
// Consent
emailOptOut Boolean @default(false)
smsOptOut Boolean @default(false)
doNotContact Boolean @default(false)
// Self-service profile
profileToken String? @unique // Random hex token for public access
profileTokenExpiresAt DateTime? // null = never expires
profilePasswordHash String? // bcrypt hash; null = no password
coverPhotoPath String? // Path to processed cover photo on disk
lastSelfEditAt DateTime? // Track last self-service edit
// Source tracking
primarySource ContactSource @default(MANUAL)
// Links to existing models
userId String? @unique
user User? @relation("UserContact", fields: [userId], references: [id], onDelete: SetNull)
// Merge support
mergedIntoId String?
mergedInto Contact? @relation("ContactMerge", fields: [mergedIntoId], references: [id], onDelete: SetNull)
mergedContacts Contact[] @relation("ContactMerge")
// Audit
createdByUserId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
addresses ContactAddress[]
emails ContactEmail[]
phones ContactPhone[]
connectionsFrom ContactConnection[] @relation("ConnectionFrom")
connectionsTo ContactConnection[] @relation("ConnectionTo")
activities ContactActivity[]
smsConversations SmsConversation[] @relation("ContactSmsConversations")
pollVotes SchedulingPollVote[] @relation("PollVoteContact")
participantNeeds ParticipantNeeds? @relation("ContactParticipantNeeds")
@@index([email])
@@index([phone])
@@index([displayName])
@@index([primarySource])
@@index([mergedIntoId])
@@map("contacts")
}
model CrmTag {
id String @id @default(cuid())
name String @unique
slug String @unique
description String?
color String? // Hex, e.g. "#1890ff"
listmonkListId Int? // Corresponding Listmonk list ID
contactCount Int @default(0) // Denormalized count
createdByUserId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([name])
@@map("crm_tags")
}
model ContactAddress {
id String @id @default(cuid())
contactId String
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
addressId String
address Address @relation(fields: [addressId], references: [id], onDelete: Cascade)
isPrimary Boolean @default(false)
createdAt DateTime @default(now())
@@unique([contactId, addressId])
@@index([addressId])
@@map("contact_addresses")
}
model ContactEmail {
id String @id @default(cuid())
contactId String
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
email String
label String? // "Personal", "Work", "Campaign", etc.
isPrimary Boolean @default(false)
createdAt DateTime @default(now())
@@unique([contactId, email])
@@index([email])
@@index([contactId])
@@map("contact_emails")
}
model ContactPhone {
id String @id @default(cuid())
contactId String
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
phone String
label String? // "Mobile", "Home", "Work", etc.
isPrimary Boolean @default(false)
createdAt DateTime @default(now())
@@unique([contactId, phone])
@@index([phone])
@@index([contactId])
@@map("contact_phones")
}
model ContactConnection {
id String @id @default(cuid())
fromContactId String
fromContact Contact @relation("ConnectionFrom", fields: [fromContactId], references: [id], onDelete: Cascade)
toContactId String
toContact Contact @relation("ConnectionTo", fields: [toContactId], references: [id], onDelete: Cascade)
type ConnectionType
label String?
notes String? @db.Text
isBidirectional Boolean @default(true)
createdAt DateTime @default(now())
@@unique([fromContactId, toContactId, type])
@@index([toContactId])
@@map("contact_connections")
}
model ContactActivity {
id String @id @default(cuid())
contactId String
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
type ContactActivityType
title String
description String? @db.Text
metadata Json?
occurredAt DateTime @default(now())
createdAt DateTime @default(now())
@@index([contactId, occurredAt(sort: Desc)])
@@map("contact_activities")
}
// ============================================================================
// MEETINGS (JITSI)
// ============================================================================
model Meeting {
id String @id @default(cuid())
slug String @unique
title String
description String?
jitsiRoom String @unique @map("jitsi_room")
isActive Boolean @default(true) @map("is_active")
createdByUserId String @map("created_by_user_id")
createdBy User @relation("MeetingCreator", fields: [createdByUserId], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
startTime DateTime? @map("start_time")
endTime DateTime? @map("end_time")
// Reverse relations (one-to-one)
shift Shift? @relation("ShiftMeeting")
group SocialGroup? @relation("GroupMeeting")
ticketedEvent TicketedEvent? @relation("EventMeeting")
@@map("meetings")
}
// ============================================================================
// SCHEDULING POLLS (Meeting Planner)
// ============================================================================
enum SchedulingPollStatus {
OPEN
CLOSED
FINALIZED
CANCELLED
}
enum PollVoteValue {
YES
IF_NEED_BE
NO
}
model SchedulingPoll {
id String @id @default(cuid())
slug String @unique
title String
description String? @db.Text
location String?
status SchedulingPollStatus @default(OPEN)
timezone String @default("America/Edmonton")
finalizedOptionId String? @unique @map("finalized_option_id")
finalizedOption SchedulingPollOption? @relation("FinalizedOption", fields: [finalizedOptionId], references: [id], onDelete: SetNull)
convertedShiftId String? @unique @map("converted_shift_id")
convertedShift Shift? @relation("PollConvertedShift", fields: [convertedShiftId], references: [id], onDelete: SetNull)
convertedGancioEventId Int? @map("converted_gancio_event_id")
convertedCalendarItemId String? @map("converted_calendar_item_id")
votingDeadline DateTime? @map("voting_deadline")
autoFinalize Boolean @default(false) @map("auto_finalize")
autoFinalizeThreshold Int? @map("auto_finalize_threshold")
autoConvertToCalendar Boolean @default(false) @map("auto_convert_to_calendar")
autoConvertToGancio Boolean @default(false) @map("auto_convert_to_gancio")
autoConvertToShift Boolean @default(false) @map("auto_convert_to_shift")
tieBreaker String @default("earliest") @map("tie_breaker")
autoEnrollVoters Boolean @default(true) @map("auto_enroll_voters")
autoFinalizeJobId String? @map("auto_finalize_job_id")
allowAnonymous Boolean @default(true) @map("allow_anonymous")
isPrivate Boolean @default(false) @map("is_private")
notifyOnVote Boolean @default(true) @map("notify_on_vote")
createdByUserId String @map("created_by_user_id")
createdBy User @relation("PollCreator", fields: [createdByUserId], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
options SchedulingPollOption[] @relation("PollOptions")
votes SchedulingPollVote[] @relation("PollVotes")
comments SchedulingPollComment[] @relation("PollComments")
agenda MeetingAgenda? @relation("PollAgenda")
@@index([createdByUserId])
@@index([status])
@@map("scheduling_polls")
}
model SchedulingPollOption {
id String @id @default(cuid())
pollId String @map("poll_id")
poll SchedulingPoll @relation("PollOptions", fields: [pollId], references: [id], onDelete: Cascade)
date DateTime @db.Date
startTime String @map("start_time") // HH:MM
endTime String @map("end_time") // HH:MM
sortOrder Int @default(0) @map("sort_order")
votes SchedulingPollVote[] @relation("OptionVotes")
// Reverse 1:1 for finalized option
finalizedForPoll SchedulingPoll? @relation("FinalizedOption")
@@index([pollId])
@@map("scheduling_poll_options")
}
model SchedulingPollVote {
id String @id @default(cuid())
pollId String @map("poll_id")
poll SchedulingPoll @relation("PollVotes", fields: [pollId], references: [id], onDelete: Cascade)
optionId String @map("option_id")
option SchedulingPollOption @relation("OptionVotes", fields: [optionId], references: [id], onDelete: Cascade)
userId String? @map("user_id")
user User? @relation("PollVoter", fields: [userId], references: [id], onDelete: SetNull)
voterName String @map("voter_name")
voterEmail String? @map("voter_email")
voterToken String? @map("voter_token") // anonymous edit access (cuid)
contactId String? @map("contact_id")
contact Contact? @relation("PollVoteContact", fields: [contactId], references: [id], onDelete: SetNull)
value PollVoteValue
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([optionId, userId])
@@unique([optionId, voterToken])
@@index([pollId])
@@index([contactId])
@@map("scheduling_poll_votes")
}
model SchedulingPollComment {
id String @id @default(cuid())
pollId String @map("poll_id")
poll SchedulingPoll @relation("PollComments", fields: [pollId], references: [id], onDelete: Cascade)
userId String? @map("user_id")
user User? @relation("PollCommenter", fields: [userId], references: [id], onDelete: SetNull)
authorName String @map("author_name")
content String @db.Text
createdAt DateTime @default(now()) @map("created_at")
@@index([pollId])
@@map("scheduling_poll_comments")
}
// ============================================================================
// SOCIAL: INVITE / REFERRAL SYSTEM
// ============================================================================
model InviteCode {
id String @id @default(cuid())
code String @unique
createdByUserId String @map("created_by_user_id")
maxUses Int @default(0) @map("max_uses") // 0 = unlimited
usedCount Int @default(0) @map("used_count")
expiresAt DateTime? @map("expires_at")
isActive Boolean @default(true) @map("is_active")
note String?
createdAt DateTime @default(now()) @map("created_at")
// Relations
createdBy User @relation("InviteCodesCreated", fields: [createdByUserId], references: [id])
referrals Referral[] @relation("InviteCodeReferrals")
@@index([code], map: "idx_invite_codes_code")
@@index([createdByUserId], map: "idx_invite_codes_created_by")
@@map("invite_codes")
}
model Referral {
id Int @id @default(autoincrement())
referrerId String @map("referrer_id")
referredUserId String @unique @map("referred_user_id")
inviteCodeId String? @map("invite_code_id")
referralSource String? @map("referral_source")
completedAt DateTime @default(now()) @map("completed_at")
// Relations
referrer User @relation("ReferralsMade", fields: [referrerId], references: [id])
referredUser User @relation("ReferredBy", fields: [referredUserId], references: [id])
inviteCode InviteCode? @relation("InviteCodeReferrals", fields: [inviteCodeId], references: [id])
@@index([referrerId], map: "idx_referrals_referrer")
@@map("referrals")
}
// ============================================================================
// SOCIAL: IMPACT STORIES / CAMPAIGN VICTORIES
// ============================================================================
enum ImpactStoryType {
MILESTONE
VICTORY
RESPONSE
CUSTOM
}
enum ImpactStoryStatus {
DRAFT
PUBLISHED
ARCHIVED
}
model ImpactStory {
id String @id @default(cuid())
campaignId String @map("campaign_id")
type ImpactStoryType
status ImpactStoryStatus @default(DRAFT)
title String
body String @db.Text
coverImageUrl String? @map("cover_image_url")
milestoneValue Int? @map("milestone_value")
milestoneMetric String? @map("milestone_metric")
createdByUserId String? @map("created_by_user_id")
publishedAt DateTime? @map("published_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
campaign Campaign @relation("CampaignStories", fields: [campaignId], references: [id])
createdBy User? @relation("ImpactStoryCreator", fields: [createdByUserId], references: [id])
@@index([campaignId], map: "idx_impact_stories_campaign")
@@index([status], map: "idx_impact_stories_status")
@@index([type], map: "idx_impact_stories_type")
@@map("impact_stories")
}
model CampaignMilestone {
id Int @id @default(autoincrement())
campaignId String @map("campaign_id")
metric String // "emails_sent", "verified_responses"
threshold Int
reachedAt DateTime @default(now()) @map("reached_at")
storyGenerated Boolean @default(false) @map("story_generated")
// Relations
campaign Campaign @relation("CampaignMilestones", fields: [campaignId], references: [id])
@@unique([campaignId, metric, threshold])
@@map("campaign_milestones")
}
// ============================================================================
// SOCIAL: VOLUNTEER SPOTLIGHT / WALL OF FAME
// ============================================================================
enum SpotlightStatus {
NOMINATED
APPROVED
FEATURED
ARCHIVED
}
model VolunteerSpotlight {
id String @id @default(cuid())
userId String @map("user_id")
status SpotlightStatus @default(NOMINATED)
headline String?
story String? @db.Text
featuredMonth String? @map("featured_month") // "2026-03"
nominatedByUserId String? @map("nominated_by_user_id")
approvedByUserId String? @map("approved_by_user_id")
approvedAt DateTime? @map("approved_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation("SpotlightUser", fields: [userId], references: [id])
nominatedBy User? @relation("SpotlightNominator", fields: [nominatedByUserId], references: [id])
approvedBy User? @relation("SpotlightApprover", fields: [approvedByUserId], references: [id])
@@index([userId], map: "idx_volunteer_spotlights_user")
@@index([status], map: "idx_volunteer_spotlights_status")
@@index([featuredMonth], map: "idx_volunteer_spotlights_month")
@@map("volunteer_spotlights")
}
// ============================================================================
// SOCIAL: TEAM CHALLENGES
// ============================================================================
enum ChallengeStatus {
DRAFT
UPCOMING
ACTIVE
COMPLETED
CANCELLED
}
enum ChallengeMetric {
DOORS_KNOCKED
EMAILS_SENT
SHIFTS_ATTENDED
RESPONSES_SUBMITTED
REFERRALS_MADE
}
model Challenge {
id String @id @default(cuid())
title String
description String? @db.Text
metric ChallengeMetric
status ChallengeStatus @default(DRAFT)
startsAt DateTime @map("starts_at")
endsAt DateTime @map("ends_at")
minTeamSize Int @default(2) @map("min_team_size")
maxTeamSize Int @default(10) @map("max_team_size")
maxTeams Int? @map("max_teams") // null = unlimited
createdByUserId String @map("created_by_user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
createdBy User @relation("ChallengesCreated", fields: [createdByUserId], references: [id])
teams ChallengeTeam[] @relation("ChallengeTeams")
@@index([status], map: "idx_challenges_status")
@@index([startsAt], map: "idx_challenges_starts_at")
@@map("challenges")
}
model ChallengeTeam {
id String @id @default(cuid())
challengeId String @map("challenge_id")
name String
captainUserId String @map("captain_user_id")
score Int @default(0)
lastScoredAt DateTime? @map("last_scored_at")
createdAt DateTime @default(now()) @map("created_at")
// Relations
challenge Challenge @relation("ChallengeTeams", fields: [challengeId], references: [id])
captain User @relation("ChallengeTeamsCaptained", fields: [captainUserId], references: [id])
members ChallengeTeamMember[] @relation("ChallengeTeamMembers")
@@unique([challengeId, name])
@@index([challengeId], map: "idx_challenge_teams_challenge")
@@index([score], map: "idx_challenge_teams_score")
@@map("challenge_teams")
}
model ChallengeTeamMember {
id Int @id @default(autoincrement())
teamId String @map("team_id")
userId String @map("user_id")
score Int @default(0)
joinedAt DateTime @default(now()) @map("joined_at")
// Relations
team ChallengeTeam @relation("ChallengeTeamMembers", fields: [teamId], references: [id], onDelete: Cascade)
user User @relation("ChallengeParticipations", fields: [userId], references: [id])
@@unique([teamId, userId])
@@index([userId], map: "idx_challenge_team_members_user")
@@map("challenge_team_members")
}
// ============================================================================
// TICKETED EVENTS
// ============================================================================
enum TicketedEventStatus {
DRAFT
PENDING_APPROVAL
PUBLISHED
CANCELLED
COMPLETED
}
enum TicketedEventVisibility {
PUBLIC
UNLISTED
PRIVATE
}
enum TicketTierType {
PAID
FREE
DONATION
}
enum TicketStatus {
VALID
CHECKED_IN
CANCELLED
REFUNDED
}
enum EventFormat {
IN_PERSON
ONLINE
HYBRID
}
model TicketedEvent {
id String @id @default(cuid())
slug String @unique
title String
description String? @db.Text
richDescription String? @db.Text @map("rich_description")
// Schedule
date DateTime @db.Date
startTime String @map("start_time")
endTime String @map("end_time")
doorsOpenTime String? @map("doors_open_time")
// Venue
venueName String? @map("venue_name")
venueAddress String? @map("venue_address")
latitude Decimal? @db.Decimal(10, 7)
longitude Decimal? @db.Decimal(10, 7)
// Status
status TicketedEventStatus @default(DRAFT)
visibility TicketedEventVisibility @default(PUBLIC)
inviteCode String? @unique @map("invite_code")
// Media
coverImageUrl String? @map("cover_image_url")
// Capacity
maxAttendees Int? @map("max_attendees")
currentAttendees Int @default(0) @map("current_attendees")
// Gancio sync
gancioEventId Int? @map("gancio_event_id")
// Format & Meeting
eventFormat EventFormat @default(IN_PERSON) @map("event_format")
meetingId String? @unique @map("meeting_id")
// Creator
createdByUserId String @map("created_by_user_id")
organizerName String? @map("organizer_name")
organizerEmail String? @map("organizer_email")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
createdBy User @relation("EventCreator", fields: [createdByUserId], references: [id])
meeting Meeting? @relation("EventMeeting", fields: [meetingId], references: [id], onDelete: SetNull)
ticketTiers TicketTier[] @relation("EventTiers")
tickets Ticket[] @relation("EventTickets")
checkIns CheckIn[] @relation("EventCheckIns")
@@index([status], map: "idx_ticketed_events_status")
@@index([date], map: "idx_ticketed_events_date")
@@index([visibility], map: "idx_ticketed_events_visibility")
@@index([createdByUserId], map: "idx_ticketed_events_creator")
@@map("ticketed_events")
}
model TicketTier {
id String @id @default(cuid())
eventId String @map("event_id")
name String
description String?
tierType TicketTierType @map("tier_type")
priceCAD Int @default(0) @map("price_cad") // In cents
minDonationCAD Int? @map("min_donation_cad") // In cents
maxQuantity Int? @map("max_quantity")
soldCount Int @default(0) @map("sold_count")
reservedCount Int @default(0) @map("reserved_count") // Pending checkout sessions
maxPerOrder Int @default(10) @map("max_per_order")
salesStartAt DateTime? @map("sales_start_at")
salesEndAt DateTime? @map("sales_end_at")
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
event TicketedEvent @relation("EventTiers", fields: [eventId], references: [id], onDelete: Cascade)
tickets Ticket[] @relation("TierTickets")
@@index([eventId], map: "idx_ticket_tiers_event")
@@map("ticket_tiers")
}
model Ticket {
id String @id @default(cuid())
ticketCode String @unique @map("ticket_code")
tokenHash String @unique @map("token_hash")
eventId String @map("event_id")
tierId String @map("tier_id")
orderId String? @map("order_id")
holderEmail String @map("holder_email")
holderName String? @map("holder_name")
userId String? @map("user_id")
status TicketStatus @default(VALID)
checkedInAt DateTime? @map("checked_in_at")
checkedInByUserId String? @map("checked_in_by_user_id")
issuedAt DateTime @default(now()) @map("issued_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
event TicketedEvent @relation("EventTickets", fields: [eventId], references: [id])
tier TicketTier @relation("TierTickets", fields: [tierId], references: [id])
order Order? @relation("TicketOrder", fields: [orderId], references: [id])
holder User? @relation("TicketHolder", fields: [userId], references: [id])
checkIns CheckIn[] @relation("TicketCheckIns")
@@index([eventId], map: "idx_tickets_event")
@@index([tierId], map: "idx_tickets_tier")
@@index([orderId], map: "idx_tickets_order")
@@index([holderEmail], map: "idx_tickets_holder_email")
@@index([status], map: "idx_tickets_status")
@@map("tickets")
}
model CheckIn {
id String @id @default(cuid())
ticketId String @map("ticket_id")
eventId String @map("event_id")
checkedInByUserId String? @map("checked_in_by_user_id")
method String // "QR" | "MANUAL" | "CODE"
checkedInAt DateTime @default(now()) @map("checked_in_at")
notes String?
// Relations
ticket Ticket @relation("TicketCheckIns", fields: [ticketId], references: [id])
event TicketedEvent @relation("EventCheckIns", fields: [eventId], references: [id])
checkedInBy User? @relation("CheckInUser", fields: [checkedInByUserId], references: [id])
@@index([eventId], map: "idx_checkins_event")
@@index([ticketId], map: "idx_checkins_ticket")
@@map("check_ins")
}
// ============================================================================
// SOCIAL CALENDAR
// ============================================================================
enum CalendarLayerType {
SYSTEM
USER
EXTERNAL
}
enum CalendarSystemType {
SHIFTS
TICKETS
POLLS
PUBLIC_EVENTS
}
enum CalendarVisibility {
PRIVATE
FRIENDS
PUBLIC
}
enum CalendarItemType {
EVENT
TIME_BLOCK
REMINDER
}
enum CalendarBusyStatus {
BUSY
TENTATIVE
FREE
}
enum CalendarShowDetailsTo {
NOBODY
FRIENDS
EVERYONE
}
enum CalendarItemSource {
MANUAL
ICS_FEED
POLL
}
enum CalendarRecurrenceFrequency {
DAILY
WEEKLY
BIWEEKLY
MONTHLY
}
enum CalendarFeedStatus {
OK
ERROR
PENDING
}
enum CalendarFeedInterval {
FIFTEEN_MIN
HOURLY
SIX_HOUR
DAILY
}
enum SharedViewType {
MANUAL
ROLE_BASED
}
enum SharedViewScope {
MEMBERS
PUBLIC
}
enum SharedViewMemberStatus {
INVITED
ACCEPTED
DECLINED
}
model CalendarLayer {
id String @id @default(cuid())
userId String @map("user_id")
name String
layerType CalendarLayerType @map("layer_type")
systemType CalendarSystemType? @map("system_type")
color String @default("#1890ff")
visibility CalendarVisibility @default(PRIVATE)
isEnabled Boolean @default(true) @map("is_enabled")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation("CalendarLayerOwner", fields: [userId], references: [id], onDelete: Cascade)
items CalendarItem[] @relation("CalendarLayerItems")
feed CalendarFeed? @relation("CalendarFeedLayer")
@@unique([userId, systemType], map: "idx_calendar_layers_user_system")
@@index([userId], map: "idx_calendar_layers_user")
@@map("calendar_layers")
}
model CalendarItem {
id String @id @default(cuid())
userId String @map("user_id")
layerId String @map("layer_id")
title String
description String? @db.Text
date DateTime @db.Date
startTime String @map("start_time") // HH:MM
endTime String @map("end_time") // HH:MM
isAllDay Boolean @default(false) @map("is_all_day")
itemType CalendarItemType @default(EVENT) @map("item_type")
location String?
color String?
visibility CalendarVisibility? // null = inherit from layer
busyStatus CalendarBusyStatus @default(BUSY) @map("busy_status")
showDetailsTo CalendarShowDetailsTo @default(FRIENDS) @map("show_details_to")
// Recurrence
recurrenceRule Json? @map("recurrence_rule")
recurrenceEnd DateTime? @map("recurrence_end")
seriesId String? @map("series_id")
isException Boolean @default(false) @map("is_exception")
// Source tracking
sourceType CalendarItemSource @default(MANUAL) @map("source_type")
sourceId String? @map("source_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation("CalendarItemOwner", fields: [userId], references: [id], onDelete: Cascade)
layer CalendarLayer @relation("CalendarLayerItems", fields: [layerId], references: [id], onDelete: Cascade)
@@index([userId, date], map: "idx_calendar_items_user_date")
@@index([layerId, date], map: "idx_calendar_items_layer_date")
@@index([seriesId], map: "idx_calendar_items_series")
@@index([sourceType, sourceId], map: "idx_calendar_items_source")
@@map("calendar_items")
}
model CalendarFeed {
id String @id @default(cuid())
userId String @map("user_id")
name String
url String
layerId String @unique @map("layer_id")
refreshInterval CalendarFeedInterval @default(HOURLY) @map("refresh_interval")
lastFetchedAt DateTime? @map("last_fetched_at")
lastStatus CalendarFeedStatus @default(PENDING) @map("last_status")
lastError String? @map("last_error")
itemCount Int @default(0) @map("item_count")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation("CalendarFeedOwner", fields: [userId], references: [id], onDelete: Cascade)
layer CalendarLayer @relation("CalendarFeedLayer", fields: [layerId], references: [id], onDelete: Cascade)
@@index([userId], map: "idx_calendar_feeds_user")
@@map("calendar_feeds")
}
model SharedCalendarView {
id String @id @default(cuid())
name String
description String? @db.Text
ownerId String @map("owner_id")
viewType SharedViewType @default(MANUAL) @map("view_type")
autoIncludeRoles Json? @map("auto_include_roles")
includedLayerTypes Json @default("[]") @map("included_layer_types")
shareScope SharedViewScope @default(MEMBERS) @map("share_scope")
shareToken String? @unique @map("share_token")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
owner User @relation("SharedViewOwner", fields: [ownerId], references: [id], onDelete: Cascade)
members SharedCalendarMember[] @relation("SharedViewMembers")
comments SharedViewComment[] @relation("SharedViewComments")
reactions SharedViewReaction[] @relation("SharedViewReactions")
@@index([ownerId], map: "idx_shared_views_owner")
@@map("shared_calendar_views")
}
model SharedCalendarMember {
id String @id @default(cuid())
viewId String @map("view_id")
userId String @map("user_id")
status SharedViewMemberStatus @default(INVITED)
color String @default("#1890ff")
joinedAt DateTime? @map("joined_at")
// Relations
view SharedCalendarView @relation("SharedViewMembers", fields: [viewId], references: [id], onDelete: Cascade)
user User @relation("SharedViewMember", fields: [userId], references: [id], onDelete: Cascade)
@@unique([viewId, userId], map: "idx_shared_members_view_user")
@@index([userId], map: "idx_shared_members_user")
@@map("shared_calendar_members")
}
model SharedViewComment {
id String @id @default(cuid())
viewId String @map("view_id")
userId String @map("user_id")
itemDate String @map("item_date") // YYYY-MM-DD
itemId String? @map("item_id") // specific item reference
content String @db.Text
createdAt DateTime @default(now()) @map("created_at")
// Relations
view SharedCalendarView @relation("SharedViewComments", fields: [viewId], references: [id], onDelete: Cascade)
user User @relation("SharedViewCommentUser", fields: [userId], references: [id], onDelete: Cascade)
@@index([viewId, itemDate], map: "idx_shared_comments_view_date")
@@map("shared_view_comments")
}
model SharedViewReaction {
id String @id @default(cuid())
viewId String @map("view_id")
userId String @map("user_id")
itemId String @map("item_id")
emoji String
createdAt DateTime @default(now()) @map("created_at")
// Relations
view SharedCalendarView @relation("SharedViewReactions", fields: [viewId], references: [id], onDelete: Cascade)
user User @relation("SharedViewReactionUser", fields: [userId], references: [id], onDelete: Cascade)
@@unique([viewId, userId, itemId, emoji], map: "idx_shared_reactions_unique")
@@map("shared_view_reactions")
}
model CalendarExportToken {
id String @id @default(cuid())
userId String @map("user_id")
token String @unique
includePersonal Boolean @default(false) @map("include_personal")
includeLayers Json? @map("include_layers")
createdAt DateTime @default(now()) @map("created_at")
// Relations
user User @relation("CalendarExportTokenOwner", fields: [userId], references: [id], onDelete: Cascade)
@@index([userId], map: "idx_calendar_export_tokens_user")
@@map("calendar_export_tokens")
}
// ============================================================================
// DOCS COLLABORATION
// ============================================================================
model DocCollabState {
id String @id @default(cuid())
documentId String @unique @map("document_id") // file path, e.g. "admin/index.md"
state Bytes // Y.Doc binary state
updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now()) @map("created_at")
@@map("doc_collab_state")
}
// ============================================================================
// PARTICIPANT NEEDS
// ============================================================================
model ParticipantNeeds {
id String @id @default(cuid())
userId String? @unique @map("user_id")
user User? @relation("UserParticipantNeeds", fields: [userId], references: [id], onDelete: SetNull)
contactId String? @unique @map("contact_id")
contact Contact? @relation("ContactParticipantNeeds", fields: [contactId], references: [id], onDelete: SetNull)
// Accessibility
needsWheelchair Boolean @default(false) @map("needs_wheelchair")
needsGroundFloor Boolean @default(false) @map("needs_ground_floor")
needsHearingLoop Boolean @default(false) @map("needs_hearing_loop")
needsSignLanguage Boolean @default(false) @map("needs_sign_language")
otherAccessibility String? @db.Text @map("other_accessibility")
// Dietary
isVegan Boolean @default(false) @map("is_vegan")
isVegetarian Boolean @default(false) @map("is_vegetarian")
isGlutenFree Boolean @default(false) @map("is_gluten_free")
isHalal Boolean @default(false) @map("is_halal")
isKosher Boolean @default(false) @map("is_kosher")
hasNutAllergy Boolean @default(false) @map("has_nut_allergy")
otherDietary String? @db.Text @map("other_dietary")
// Care barriers
needsChildcare Boolean @default(false) @map("needs_childcare")
childcareDetails String? @db.Text @map("childcare_details")
needsTransportation Boolean @default(false) @map("needs_transportation")
transportationNotes String? @db.Text @map("transportation_notes")
// Communication
preferredLanguage String? @default("en") @map("preferred_language")
needsTranslation Boolean @default(false) @map("needs_translation")
translationLanguage String? @map("translation_language")
// Consent
visibilityConsent String @default("organizer_only") @map("visibility_consent")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("participant_needs")
}
// ============================================================================
// MEETING AGENDAS & ACTION ITEMS
// ============================================================================
model MeetingAgenda {
id String @id @default(cuid())
shiftId String? @unique @map("shift_id")
shift Shift? @relation("ShiftAgenda", fields: [shiftId], references: [id], onDelete: SetNull)
pollId String? @unique @map("poll_id")
poll SchedulingPoll? @relation("PollAgenda", fields: [pollId], references: [id], onDelete: SetNull)
title String
items Json @default("[]") // Array<{ id, title, durationMinutes, presenterUserId, notes, order }>
status String @default("draft") // "draft" | "active" | "completed"
minutes MeetingMinutes?
actionItems ActionItem[]
createdByUserId String @map("created_by_user_id")
createdBy User @relation("AgendaCreator", fields: [createdByUserId], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("meeting_agendas")
}
model MeetingMinutes {
id String @id @default(cuid())
agendaId String @unique @map("agenda_id")
agenda MeetingAgenda @relation(fields: [agendaId], references: [id], onDelete: Cascade)
notes String @db.Text
decisions Json @default("[]") // Array<{ id, text, passed: boolean }>
attendees Json @default("[]") // Array<{ name, pronouns, userId? }>
approvedAt DateTime? @map("approved_at")
approvedByUserId String? @map("approved_by_user_id")
approvedBy User? @relation("MinutesApprover", fields: [approvedByUserId], references: [id], onDelete: SetNull)
createdByUserId String @map("created_by_user_id")
createdBy User @relation("MinutesCreator", fields: [createdByUserId], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("meeting_minutes")
}
model ActionItem {
id String @id @default(cuid())
agendaId String? @map("agenda_id")
agenda MeetingAgenda? @relation(fields: [agendaId], references: [id], onDelete: SetNull)
title String
description String? @db.Text
assigneeUserId String? @map("assignee_user_id")
assignee User? @relation("ActionItemAssignee", fields: [assigneeUserId], references: [id], onDelete: SetNull)
dueDate DateTime? @map("due_date")
status String @default("open") // "open" | "in_progress" | "done" | "blocked"
priority String @default("normal") // "low" | "normal" | "high" | "urgent"
completedAt DateTime? @map("completed_at")
createdByUserId String @map("created_by_user_id")
createdBy User @relation("ActionItemCreator", fields: [createdByUserId], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([assigneeUserId, status])
@@index([dueDate])
@@map("action_items")
}