Okay Wish I could say I know exactly. Will do better next time promise lol

This commit is contained in:
2026-02-26 17:47:04 -07:00
parent 2fa50b001c
commit 9e51aac570
727 changed files with 217309 additions and 5801 deletions

22
api/prisma/init-gitea-db.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
###############################################################################
# Gitea Database Initialization Script
###############################################################################
# Creates a separate PostgreSQL database for Gitea git hosting.
#
# Database: gitea
# Purpose: Stores Gitea repositories, users, and configuration
# Runs: Automatically on first PostgreSQL container startup via docker-entrypoint-initdb.d
###############################################################################
set -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
-- Create Gitea database if it doesn't exist
SELECT 'CREATE DATABASE gitea'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'gitea')\gexec
-- Grant all privileges to the main user
GRANT ALL PRIVILEGES ON DATABASE gitea TO ${POSTGRES_USER};
EOSQL
echo "Gitea database 'gitea' created successfully"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ads" ADD COLUMN "placements" JSONB DEFAULT '[]';

View File

@@ -0,0 +1,14 @@
-- AlterTable
ALTER TABLE "site_settings" ADD COLUMN "notifyVolunteerShiftThankYou" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "notify_volunteer_reengagement" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "reengagement_cooldown_days" INTEGER NOT NULL DEFAULT 30,
ADD COLUMN "reengagement_inactive_days" INTEGER NOT NULL DEFAULT 30;
-- AlterTable
ALTER TABLE "sms_conversations" ADD COLUMN "contactId" TEXT;
-- CreateIndex
CREATE INDEX "sms_conversations_contactId_idx" ON "sms_conversations"("contactId");
-- AddForeignKey
ALTER TABLE "sms_conversations" ADD CONSTRAINT "sms_conversations_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "contacts"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "landing_pages" ADD COLUMN "listed" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "site_settings" ADD COLUMN "homepage_tagline" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "site_settings" ADD COLUMN "enable_social" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "notification_preferences" ADD COLUMN "digest_frequency" TEXT NOT NULL DEFAULT 'none',
ADD COLUMN "last_digest_sent_at" TIMESTAMP(3);

View File

@@ -0,0 +1,38 @@
-- CreateEnum
CREATE TYPE "SocialGroupType" AS ENUM ('SHIFT_TEAM', 'CAMPAIGN_TEAM', 'CUSTOM');
-- CreateTable
CREATE TABLE "social_groups" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" "SocialGroupType" NOT NULL,
"reference_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "social_groups_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "social_group_members" (
"id" TEXT NOT NULL,
"group_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"joined_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "social_group_members_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "idx_social_groups_type_ref" ON "social_groups"("type", "reference_id");
-- CreateIndex
CREATE INDEX "idx_social_group_members_user" ON "social_group_members"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "idx_social_group_members_unique" ON "social_group_members"("group_id", "user_id");
-- AddForeignKey
ALTER TABLE "social_group_members" ADD CONSTRAINT "social_group_members_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "social_groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "social_group_members" ADD CONSTRAINT "social_group_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "site_settings" ADD COLUMN "enable_meet" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "meetings" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"jitsi_room" TEXT NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_by_user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"start_time" TIMESTAMP(3),
"end_time" TIMESTAMP(3),
CONSTRAINT "meetings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "meetings_slug_key" ON "meetings"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "meetings_jitsi_room_key" ON "meetings"("jitsi_room");
-- AddForeignKey
ALTER TABLE "meetings" ADD CONSTRAINT "meetings_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,20 @@
-- AlterEnum
ALTER TYPE "NotificationType" ADD VALUE 'group_call';
-- AlterTable
ALTER TABLE "shifts" ADD COLUMN "meetingId" TEXT;
-- AlterTable
ALTER TABLE "social_groups" ADD COLUMN "meeting_id" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "shifts_meetingId_key" ON "shifts"("meetingId");
-- CreateIndex
CREATE UNIQUE INDEX "social_groups_meeting_id_key" ON "social_groups"("meeting_id");
-- AddForeignKey
ALTER TABLE "shifts" ADD CONSTRAINT "shifts_meetingId_fkey" FOREIGN KEY ("meetingId") REFERENCES "meetings"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "social_groups" ADD CONSTRAINT "social_groups_meeting_id_fkey" FOREIGN KEY ("meeting_id") REFERENCES "meetings"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -116,6 +116,7 @@ model User {
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")
@@ -140,6 +141,20 @@ model User {
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")
@@map("users")
}
@@ -228,6 +243,7 @@ model Campaign {
responses RepresentativeResponse[]
customRecipients CustomRecipient[]
calls Call[]
smsCampaigns SmsCampaign[] @relation("SmsCampaigns")
@@index([moderationStatus])
@@index([isUserGenerated])
@@ -578,6 +594,7 @@ model Address {
// Relations
canvassVisits CanvassVisit[]
contactAddresses ContactAddress[]
@@index([locationId])
@@index([locationId, id])
@@ -657,6 +674,10 @@ model Shift {
// 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
@@ -793,7 +814,13 @@ model MapSettings {
qrCode2Label String?
qrCode3Url String?
qrCode3Label String?
publicMapEnabled Boolean @default(true)
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
@@ -827,6 +854,7 @@ model SiteSettings {
// Text
footerText String @default("Powered by Changemaker Lite")
loginSubtitle String @default("Admin")
homepageTagline String? @map("homepage_tagline")
// Email branding
emailFromName String @default("Changemaker Lite")
@@ -856,6 +884,27 @@ model SiteSettings {
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")
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)
@@ -865,6 +914,24 @@ model SiteSettings {
notifyVolunteerSessionSummary Boolean @default(true)
notifyVolunteerCancellation Boolean @default(true)
notifyVolunteerShiftReminder Boolean @default(true)
notifyVolunteerShiftThankYou Boolean @default(true)
// 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")
// 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
@@ -1006,6 +1073,7 @@ model LandingPage {
mkdocsHideToc Boolean @default(true)
mkdocsSkipExport Boolean @default(false)
published Boolean @default(false)
listed Boolean @default(false)
seoTitle String?
seoDescription String? @db.Text
seoImage String?
@@ -1381,6 +1449,7 @@ enum NotificationType {
upload_rejected
achievement
system
group_call
}
// ============================================================================
@@ -2086,6 +2155,7 @@ model Ad {
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")
@@ -2296,6 +2366,43 @@ model CloseFriend {
@@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
// ============================================================================
@@ -3121,6 +3228,15 @@ model SubscriptionPlan {
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[]
@@ -3237,6 +3353,9 @@ model Product {
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")
@@ -3276,16 +3395,61 @@ model Order {
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User? @relation("UserOrders", fields: [userId], references: [id])
product Product? @relation(fields: [productId], references: [id])
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)
@@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")
@@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")
@@ -3333,19 +3497,21 @@ model Notification {
}
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")
createdAt DateTime @default(now()) @map("created_at")
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])
user User @relation("NotificationPreferences", fields: [userId], references: [id])
@@index([userId], map: "idx_notification_preferences_user")
@@map("notification_preferences")
@@ -3670,3 +3836,455 @@ model PhotoReaction {
@@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
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
}
model Contact {
id String @id @default(cuid())
displayName String
firstName String?
lastName String?
email String?
phone 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")
@@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")
@@map("meetings")
}

View File

@@ -98,11 +98,50 @@ async function main() {
smtpActiveProvider: isMailhog ? 'mailhog' : 'production',
emailTestMode: env.EMAIL_TEST_MODE === 'true',
testEmailRecipient: env.TEST_EMAIL_RECIPIENT,
navConfig: {
items: [
{ id: 'home', label: 'Home', path: '/', icon: 'HomeOutlined', enabled: true, order: 0, type: 'builtin', external: true },
{ id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'SendOutlined', enabled: true, order: 1, type: 'builtin', featureFlag: 'enableInfluence' },
{ id: 'map', label: 'Map', path: '/map', icon: 'EnvironmentOutlined', enabled: true, order: 2, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'shifts', label: 'Shifts', path: '/shifts', icon: 'CalendarOutlined', enabled: true, order: 3, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'events', label: 'Events', path: '/events', icon: 'CalendarOutlined', enabled: true, order: 4, type: 'builtin', featureFlag: 'enableEvents', external: true },
{ id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'PlayCircleOutlined', enabled: true, order: 5, type: 'builtin', featureFlag: 'enableMediaFeatures' },
{ id: 'pricing', label: 'Pricing', path: '/pricing', icon: 'DollarOutlined', enabled: true, order: 6, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'shop', label: 'Shop', path: '/shop', icon: 'ShoppingOutlined', enabled: true, order: 7, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'donate', label: 'Donate', path: '/donate', icon: 'HeartOutlined', enabled: true, order: 8, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'landing', label: 'Website', path: '$landing', icon: 'GlobalOutlined', enabled: false, order: 9, type: 'builtin', external: true },
{ id: 'docs', label: 'Docs', path: '$docs', icon: 'BookOutlined', enabled: false, order: 10, type: 'builtin', external: true },
],
},
},
});
console.log('Created SiteSettings with SMTP config from .env');
console.log('Created SiteSettings with SMTP config + navConfig from .env');
} else {
console.log('SiteSettings already exists, skipping SMTP seeding');
// Seed navConfig if null (existing installations)
if (!existingSettings.navConfig) {
const defaultNavConfig = {
items: [
{ id: 'home', label: 'Home', path: '/', icon: 'HomeOutlined', enabled: true, order: 0, type: 'builtin', external: true },
{ id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'SendOutlined', enabled: true, order: 1, type: 'builtin', featureFlag: 'enableInfluence' },
{ id: 'map', label: 'Map', path: '/map', icon: 'EnvironmentOutlined', enabled: true, order: 2, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'shifts', label: 'Shifts', path: '/shifts', icon: 'CalendarOutlined', enabled: true, order: 3, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'events', label: 'Events', path: '/events', icon: 'CalendarOutlined', enabled: true, order: 4, type: 'builtin', featureFlag: 'enableEvents', external: true },
{ id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'PlayCircleOutlined', enabled: true, order: 5, type: 'builtin', featureFlag: 'enableMediaFeatures' },
{ id: 'pricing', label: 'Pricing', path: '/pricing', icon: 'DollarOutlined', enabled: true, order: 6, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'shop', label: 'Shop', path: '/shop', icon: 'ShoppingOutlined', enabled: true, order: 7, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'donate', label: 'Donate', path: '/donate', icon: 'HeartOutlined', enabled: true, order: 8, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'landing', label: 'Website', path: '$landing', icon: 'GlobalOutlined', enabled: false, order: 9, type: 'builtin', external: true },
{ id: 'docs', label: 'Docs', path: '$docs', icon: 'BookOutlined', enabled: false, order: 10, type: 'builtin', external: true },
],
};
await prisma.siteSettings.update({
where: { id: existingSettings.id },
data: { navConfig: defaultNavConfig },
});
console.log('Seeded default navConfig on existing SiteSettings');
}
}
// Create default page blocks for landing page builder
@@ -345,6 +384,67 @@ async function main() {
title: 'Upcoming Events',
},
},
{
id: 'default-photo',
type: 'photo',
label: 'Photo',
category: 'Media',
sortOrder: 14,
schema: {
photoId: { type: 'number', label: 'Photo ID', required: true },
size: { type: 'select', label: 'Size', options: ['thumb', 'medium', 'large'], default: 'large' },
caption: { type: 'string', label: 'Caption' },
linkToGallery: { type: 'boolean', label: 'Link to Gallery', default: true },
alignment: { type: 'select', label: 'Alignment', options: ['left', 'center', 'right'], default: 'center' },
maxWidth: { type: 'string', label: 'Max Width', default: '100%' },
},
defaults: {
photoId: null,
size: 'large',
caption: '',
linkToGallery: true,
alignment: 'center',
maxWidth: '100%',
},
},
{
id: 'default-photo-card',
type: 'photo-card',
label: 'Photo Card',
category: 'Media',
sortOrder: 15,
schema: {
photoId: { type: 'number', label: 'Photo ID', required: true },
title: { type: 'string', label: 'Title' },
description: { type: 'string', label: 'Description' },
showMetadata: { type: 'boolean', label: 'Show Metadata (format, dimensions)', default: true },
},
defaults: {
photoId: null,
title: '',
description: '',
showMetadata: true,
},
},
{
id: 'default-photo-album',
type: 'photo-album',
label: 'Photo Album',
category: 'Media',
sortOrder: 16,
schema: {
albumId: { type: 'number', label: 'Album ID', required: true },
columns: { type: 'select', label: 'Columns', options: ['2', '3', '4'], default: '3' },
maxPhotos: { type: 'number', label: 'Max Photos', default: 12 },
showTitle: { type: 'boolean', label: 'Show Album Title', default: true },
},
defaults: {
albumId: null,
columns: '3',
maxPhotos: 12,
showTitle: true,
},
},
];
for (const block of defaultBlocks) {
@@ -648,6 +748,38 @@ async function seedEmailTemplates(admin: { id: string; email: string }) {
{ key: 'SIGNUP_URL', label: 'Signup URL', description: 'URL to browse available shifts', isRequired: true, isConditional: false, sampleValue: 'https://app.cmlite.org/shifts', sortOrder: 5 },
],
},
{
key: 'volunteer-shift-thank-you',
name: 'Volunteer: Post-Shift Thank You',
description: 'Thank-you email sent to a volunteer 2 hours after their shift ends',
category: EmailTemplateCategory.MAP,
subjectLine: 'Thank you for volunteering — {{SHIFT_TITLE}}',
isSystem: true,
variables: [
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 0 },
{ key: 'VOLUNTEER_NAME', label: 'Volunteer Name', description: 'Name of the volunteer', isRequired: true, isConditional: false, sampleValue: 'Jane Doe', sortOrder: 1 },
{ key: 'SHIFT_TITLE', label: 'Shift Title', description: 'Title of the shift', isRequired: true, isConditional: false, sampleValue: 'Weekend Canvassing - Downtown', sortOrder: 2 },
{ key: 'SHIFT_DATE', label: 'Shift Date', description: 'Date of the shift', isRequired: true, isConditional: false, sampleValue: 'Saturday, February 22, 2026', sortOrder: 3 },
{ key: 'SHIFT_TIME', label: 'Shift Time', description: 'Time range of the shift', isRequired: true, isConditional: false, sampleValue: '10:00 AM — 2:00 PM', sortOrder: 4 },
{ key: 'SHIFT_LOCATION', label: 'Shift Location', description: 'Meeting location for the shift', isRequired: false, isConditional: true, sampleValue: 'City Hall', sortOrder: 5 },
{ key: 'SIGNUP_URL', label: 'Signup URL', description: 'URL to browse upcoming shifts', isRequired: true, isConditional: false, sampleValue: 'https://app.cmlite.org/shifts', sortOrder: 6 },
],
},
{
key: 'volunteer-reengagement',
name: 'Volunteer: Re-Engagement',
description: 'Re-engagement email sent to volunteers who have been inactive for a configurable period',
category: EmailTemplateCategory.MAP,
subjectLine: 'We miss you — {{ORGANIZATION_NAME}}',
isSystem: true,
variables: [
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 0 },
{ key: 'VOLUNTEER_NAME', label: 'Volunteer Name', description: 'Name of the volunteer', isRequired: true, isConditional: false, sampleValue: 'Jane Doe', sortOrder: 1 },
{ key: 'LAST_ACTIVITY_DATE', label: 'Last Activity Date', description: 'Date of the volunteer\'s last activity', isRequired: true, isConditional: false, sampleValue: 'January 15, 2026', sortOrder: 2 },
{ key: 'LAST_ACTIVITY_TYPE', label: 'Last Activity Type', description: 'Type of the volunteer\'s last activity', isRequired: true, isConditional: false, sampleValue: 'Shift Signup', sortOrder: 3 },
{ key: 'SIGNUP_URL', label: 'Signup URL', description: 'URL to browse upcoming shifts', isRequired: true, isConditional: false, sampleValue: 'https://app.cmlite.org/shifts', sortOrder: 4 },
],
},
];
let seededCount = 0;
@@ -741,6 +873,7 @@ async function seedGalleryAds() {
iconEmoji: null,
bgColor: null,
imagePath: null,
placements: ['gallery', 'docs'],
},
{
type: 'payment_subscribe',
@@ -756,6 +889,7 @@ async function seedGalleryAds() {
iconEmoji: null,
bgColor: null,
imagePath: null,
placements: ['gallery', 'pricing', 'landing_page', 'docs'],
},
{
type: 'payment_donate',
@@ -771,6 +905,7 @@ async function seedGalleryAds() {
iconEmoji: null,
bgColor: null,
imagePath: null,
placements: ['gallery', 'donate', 'landing_page', 'docs'],
},
{
type: 'payment_shop',
@@ -786,6 +921,7 @@ async function seedGalleryAds() {
iconEmoji: null,
bgColor: null,
imagePath: null,
placements: ['gallery', 'shop', 'landing_page'],
},
{
type: 'system',
@@ -801,6 +937,7 @@ async function seedGalleryAds() {
iconEmoji: null,
bgColor: null,
imagePath: null,
placements: ['gallery', 'campaigns_list', 'shifts', 'landing_page', 'docs'],
},
{
type: 'system',
@@ -816,6 +953,7 @@ async function seedGalleryAds() {
iconEmoji: null,
bgColor: null,
imagePath: null,
placements: ['gallery', 'shifts', 'campaigns_list', 'landing_page'],
},
];