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") }