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>
787 lines
24 KiB
Plaintext
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")
|
|
}
|