changemaker.lite/api/prisma/schema.prisma
bunker-admin a77306fac2 Initial v2 commit: complete rebuild with unified API + React admin
Phase 1-14 complete:
- Unified Express.js API (TypeScript, Prisma ORM, PostgreSQL 16)
- React Admin GUI (Vite + Ant Design + Zustand)
- JWT auth with refresh tokens
- Influence: Campaigns, Representatives, Responses, Email Queue
- Map: Locations, Cuts, Shifts, Canvassing System
- NAR data import infrastructure (2025 format)
- Listmonk newsletter integration
- Landing page builder (GrapesJS)
- MkDocs + Code Server integration
- Volunteer portal with GPS tracking
- Monitoring stack (Prometheus, Grafana, Alertmanager)
- Pangolin tunnel integration

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 10:05:04 -07:00

787 lines
24 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
USER
TEMP
}
enum UserStatus {
ACTIVE
INACTIVE
SUSPENDED
EXPIRED
}
enum UserCreatedVia {
ADMIN
PUBLIC_SHIFT_SIGNUP
STANDARD
}
model User {
id String @id @default(cuid())
email String @unique
password String // bcrypt hashed
name String?
phone String?
role UserRole @default(USER)
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")
campaignEmails CampaignEmail[] @relation("CampaignEmailSender")
responses RepresentativeResponse[] @relation("ResponseSubmitter")
responseUpvotes ResponseUpvote[]
shiftSignups ShiftSignup[]
locationsCreated Location[] @relation("LocationCreator")
locationsUpdated Location[] @relation("LocationUpdater")
cutsCreated Cut[] @relation("CutCreator")
canvassVisits CanvassVisit[] @relation("CanvassVisitor")
canvassSessions CanvassSession[] @relation("CanvassSessions")
trackingSessions TrackingSession[] @relation("TrackingSessions")
@@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 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?
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?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
emails CampaignEmail[]
responses RepresentativeResponse[]
customRecipients CustomRecipient[]
calls Call[]
@@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])
@@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])
@@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 {
MAPBOX
NOMINATIM
PHOTON
LOCATIONIQ
ARCGIS
UNKNOWN
}
model Location {
id String @id @default(cuid())
latitude Decimal? @db.Decimal(10, 8)
longitude Decimal? @db.Decimal(11, 8)
firstName String?
lastName String?
email String?
phone String?
unitNumber String?
supportLevel SupportLevel?
address String?
postalCode String?
province String?
federalDistrict String?
buildingUse Int? // NAR BU_USE: 1=Residential, 2=Partial, 3=Non-Residential, 4=Unknown
sign Boolean @default(false)
signSize String? // Regular, Large, Unsure
notes String? @db.Text
geocodeConfidence Int? // 0-100
geocodeProvider GeocodeProvider?
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
canvassVisits CanvassVisit[]
@@index([latitude, longitude])
@@map("locations")
}
// ============================================================================
// MAP — SHIFTS
// ============================================================================
enum ShiftStatus {
OPEN
FULL
CANCELLED
}
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)
createdBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
signups ShiftSignup[]
canvassVisits CanvassVisit[]
canvassSessions CanvassSession[]
@@index([cutId])
@@map("shifts")
}
enum SignupStatus {
CONFIRMED
CANCELLED
}
enum SignupSource {
AUTHENTICATED
PUBLIC
ADMIN
}
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 — 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[]
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?
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")
// 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("")
// Feature toggles
enableInfluence Boolean @default(true)
enableMap Boolean @default(true)
enableNewsletter Boolean @default(true)
enableLandingPages Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("site_settings")
}
// ============================================================================
// 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)
published 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())
locationId String
location Location @relation(fields: [locationId], 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([locationId])
@@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")
}