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'],
},
];

View File

@@ -109,6 +109,7 @@ const envSchema = z.object({
// Vaultwarden (password manager)
VAULTWARDEN_URL: z.string().default('http://vaultwarden-changemaker:80'),
VAULTWARDEN_ADMIN_TOKEN: z.string().default(''),
VAULTWARDEN_EMBED_PORT: z.coerce.number().default(8890),
// Rocket.Chat (team chat)
@@ -126,6 +127,13 @@ const envSchema = z.object({
GANCIO_ADMIN_PASSWORD: z.string().default(''),
GANCIO_SYNC_ENABLED: z.string().default('false'),
// Jitsi Meet (video conferencing)
ENABLE_MEET: z.string().default('false'),
JITSI_APP_ID: z.string().default('changemaker'),
JITSI_APP_SECRET: z.string().default(''),
JITSI_URL: z.string().default('http://jitsi-web-changemaker:80'),
JITSI_EMBED_PORT: z.coerce.number().default(8893),
// Pangolin (tunnel / reverse proxy)
PANGOLIN_API_URL: z.string()
.default('')
@@ -159,6 +167,23 @@ const envSchema = z.object({
MEDIA_UPLOADS: z.string().default('/media/uploads'),
MAX_UPLOAD_SIZE_GB: z.coerce.number().default(10),
// Gitea Docs Comments
GITEA_COMMENTS_ENABLED: z.string().default('false'),
GITEA_API_TOKEN: z.string().default(''),
GITEA_COMMENTS_REPO_OWNER: z.string().default(''),
GITEA_COMMENTS_REPO_NAME: z.string().default('docs-comments'),
GITEA_OAUTH_CLIENT_ID: z.string().default(''),
GITEA_OAUTH_CLIENT_SECRET: z.string().default(''),
// SMS Campaigns (Termux Android bridge)
ENABLE_SMS: z.string().default('false'),
TERMUX_API_URL: z.string().default('http://10.0.0.193:5001'),
TERMUX_API_KEY: z.string().default(''),
SMS_DELAY_BETWEEN_MS: z.coerce.number().default(3000),
SMS_MAX_RETRIES: z.coerce.number().default(3),
SMS_RESPONSE_SYNC_INTERVAL_MS: z.coerce.number().default(30000),
SMS_DEVICE_MONITOR_INTERVAL_MS: z.coerce.number().default(30000),
// Docs / Code Server
CODE_SERVER_URL: z.string().default('http://code-server-changemaker:8080'),
CODE_SERVER_PORT: z.coerce.number().default(8888),
@@ -175,8 +200,10 @@ const envSchema = z.object({
PROMETHEUS_PORT: z.coerce.number().default(9090),
GRAFANA_URL: z.string().default('http://grafana-changemaker:3000'),
GRAFANA_PORT: z.coerce.number().default(3005),
GRAFANA_EMBED_PORT: z.coerce.number().default(8894),
ALERTMANAGER_URL: z.string().default('http://alertmanager-changemaker:9093'),
ALERTMANAGER_PORT: z.coerce.number().default(9093),
ALERTMANAGER_EMBED_PORT: z.coerce.number().default(8895),
CADVISOR_URL: z.string().default('http://cadvisor-changemaker:8080'),
CADVISOR_PORT: z.coerce.number().default(8086),
NODE_EXPORTER_URL: z.string().default('http://node-exporter-changemaker:9100'),

View File

@@ -224,6 +224,142 @@ export const docsAnalyticsRateLimit = rateLimit({
},
});
export const docsCommentAnonRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:docs-comment-anon:',
}),
message: {
error: {
message: 'Too many anonymous comments, please try again later',
code: 'DOCS_COMMENT_ANON_RATE_LIMIT_EXCEEDED',
},
},
});
export const docsCommentAuthRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 30,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:docs-comment-auth:',
}),
message: {
error: {
message: 'Too many comments, please try again later',
code: 'DOCS_COMMENT_AUTH_RATE_LIMIT_EXCEEDED',
},
},
});
export const docsCommentFetchRateLimit = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 60,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:docs-comment-fetch:',
}),
message: {
error: {
message: 'Too many comment fetch requests, please slow down',
code: 'DOCS_COMMENT_FETCH_RATE_LIMIT_EXCEEDED',
},
},
});
export const profileViewRateLimit = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 60, // 60 requests per minute (shared across profile, photo, activity endpoints)
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:profile-view:',
}),
message: {
error: {
message: 'Too many profile requests, please try again later',
code: 'PROFILE_VIEW_RATE_LIMIT_EXCEEDED',
},
},
});
export const profileEditRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 20,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:profile-edit:',
}),
message: {
error: {
message: 'Too many profile edit requests, please try again later',
code: 'PROFILE_EDIT_RATE_LIMIT_EXCEEDED',
},
},
});
export const profilePhotoRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:profile-photo:',
}),
message: {
error: {
message: 'Too many photo uploads, please try again later',
code: 'PROFILE_PHOTO_RATE_LIMIT_EXCEEDED',
},
},
});
export const profilePasswordRateLimit = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Stricter than auth (10/15min) — profile passwords may be simpler
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:profile-password:',
}),
message: {
error: {
message: 'Too many password attempts, please try again later',
code: 'PROFILE_PASSWORD_RATE_LIMIT_EXCEEDED',
},
},
});
export const eventSubmissionRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:event-submit:',
}),
message: {
error: {
message: 'Too many event submissions, please try again later',
code: 'EVENT_SUBMISSION_RATE_LIMIT_EXCEEDED',
},
},
});
export const healthMetricsRateLimit = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 30, // 30 requests per minute

View File

@@ -0,0 +1,17 @@
import { Router, Request, Response, NextFunction } from 'express';
import { getPublicActivity } from './activity-public.service';
const router = Router();
// GET /api/activity/public?limit=20
router.get('/public', async (req: Request, res: Response, next: NextFunction) => {
try {
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
const items = await getPublicActivity(limit);
res.json(items);
} catch (err) {
next(err);
}
});
export { router as activityPublicRouter };

View File

@@ -0,0 +1,126 @@
import { prisma } from '../../config/database';
import { redis } from '../../config/redis';
import { siteSettingsService } from '../settings/settings.service';
export interface PublicActivityItem {
type: 'campaign_published' | 'shift_created' | 'media_published' | 'response_approved';
title: string;
description: string | null;
link: string;
timestamp: string;
}
const CACHE_KEY = 'activity:public';
const CACHE_TTL = 300; // 5 minutes
export async function getPublicActivity(limit = 20): Promise<PublicActivityItem[]> {
// Check cache
try {
const cached = await redis.get(CACHE_KEY);
if (cached) return JSON.parse(cached);
} catch { /* cache miss */ }
const settings = await siteSettingsService.getPublic();
const items: PublicActivityItem[] = [];
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // Last 30 days
const promises: Array<Promise<PublicActivityItem[]>> = [];
if (settings.enableInfluence !== false) {
promises.push(getRecentCampaigns(since, limit));
promises.push(getRecentResponses(since, limit));
}
if (settings.enableMap !== false) {
promises.push(getRecentShifts(since, limit));
}
if (settings.enableMediaFeatures !== false) {
promises.push(getRecentMedia(since, limit));
}
const results = await Promise.allSettled(promises);
for (const result of results) {
if (result.status === 'fulfilled') items.push(...result.value);
}
// Sort by timestamp descending, limit
items.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
const limited = items.slice(0, limit);
try {
await redis.setex(CACHE_KEY, CACHE_TTL, JSON.stringify(limited));
} catch { /* non-critical */ }
return limited;
}
async function getRecentCampaigns(since: Date, limit: number): Promise<PublicActivityItem[]> {
const campaigns = await prisma.campaign.findMany({
where: { status: 'ACTIVE', createdAt: { gte: since } },
select: { slug: true, title: true, createdAt: true },
orderBy: { createdAt: 'desc' },
take: limit,
});
return campaigns.map(c => ({
type: 'campaign_published' as const,
title: `New campaign: ${c.title}`,
description: null,
link: `/campaign/${c.slug}`,
timestamp: c.createdAt.toISOString(),
}));
}
async function getRecentShifts(since: Date, limit: number): Promise<PublicActivityItem[]> {
const shifts = await prisma.shift.findMany({
where: { status: 'OPEN', createdAt: { gte: since } },
select: { title: true, date: true, createdAt: true },
orderBy: { createdAt: 'desc' },
take: limit,
});
return shifts.map(s => ({
type: 'shift_created' as const,
title: `New shift: ${s.title}`,
description: null,
link: '/shifts',
timestamp: s.createdAt.toISOString(),
}));
}
async function getRecentMedia(since: Date, limit: number): Promise<PublicActivityItem[]> {
try {
const videos = await prisma.video.findMany({
where: { isPublished: true, publishedAt: { gte: since } },
select: { id: true, title: true, publishedAt: true },
orderBy: { publishedAt: 'desc' },
take: limit,
});
return videos.map((v: { id: number; title: string | null; publishedAt: Date | null }) => ({
type: 'media_published' as const,
title: `New video: ${v.title ?? 'Untitled'}`,
description: null,
link: `/gallery/watch/${v.id}`,
timestamp: (v.publishedAt ?? new Date()).toISOString(),
}));
} catch {
return [];
}
}
async function getRecentResponses(since: Date, limit: number): Promise<PublicActivityItem[]> {
const responses = await prisma.representativeResponse.findMany({
where: { status: 'APPROVED', createdAt: { gte: since } },
select: {
id: true,
createdAt: true,
campaign: { select: { slug: true, title: true } },
},
orderBy: { createdAt: 'desc' },
take: limit,
});
return responses.map((r: { id: string; createdAt: Date; campaign: { slug: string; title: string } }) => ({
type: 'response_approved' as const,
title: `New response on "${r.campaign.title}"`,
description: null,
link: `/campaign/${r.campaign.slug}/responses`,
timestamp: r.createdAt.toISOString(),
}));
}

View File

@@ -15,6 +15,7 @@ import { siteSettingsService } from '../settings/settings.service';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
import { createVerificationRateLimit, createResetRateLimit } from './auth.rate-limits';
import { profileService } from '../people/profile.service';
const router = Router();
@@ -246,6 +247,62 @@ router.post(
}
);
// GET /api/auth/me/profile-token
router.get(
'/me/profile-token',
authenticate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
// Look up existing Contact linked to this user
let contact = await prisma.contact.findUnique({ where: { userId } });
// Auto-create Contact if none exists
if (!contact) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { name: true, email: true },
});
if (!user) {
res.status(401).json({ error: { message: 'Invalid token', code: 'INVALID_TOKEN' } });
return;
}
try {
contact = await prisma.contact.create({
data: {
displayName: user.name || user.email,
firstName: user.name?.split(' ')[0] || null,
lastName: user.name?.split(' ').slice(1).join(' ') || null,
email: user.email,
userId,
primarySource: 'USER',
},
});
} catch (err: any) {
// Race condition: another request created the Contact — retry lookup
if (err.code === 'P2002') {
contact = await prisma.contact.findUnique({ where: { userId } });
}
if (!contact) throw err;
}
}
// Generate profile token if Contact doesn't have one
if (!contact.profileToken) {
const result = await profileService.generateProfileToken(contact.id);
res.json({ token: result.token });
return;
}
res.json({ token: contact.profileToken });
} catch (err) {
next(err);
}
}
);
// GET /api/auth/me
router.get(
'/me',

View File

@@ -9,6 +9,7 @@ import { siteSettingsService } from '../settings/settings.service';
import { verificationTokenService } from '../../services/verification-token.service';
import { emailService } from '../../services/email.service';
import { getPrimaryRole } from '../../utils/roles';
import { logger } from '../../utils/logger';
import type { RegisterInput } from './auth.schemas';
interface TokenPayload {
@@ -75,6 +76,24 @@ export const authService = {
data: { lastLoginAt: new Date() },
});
// Fire-and-forget: log USER_LOGIN activity on linked Contact
prisma.contact.findFirst({
where: { userId: user.id, mergedIntoId: null },
}).then(async (contact) => {
if (contact) {
await prisma.contactActivity.create({
data: {
contactId: contact.id,
type: 'USER_LOGIN',
title: 'User logged in',
description: `Login from ${user.email}`,
},
});
}
}).catch(err => {
logger.warn('Login activity logging failed:', err);
});
const tokens = await this.generateTokenPair(user);
const { password: _, ...userWithoutPassword } = user;
@@ -113,6 +132,36 @@ export const authService = {
},
});
// Fire-and-forget: auto-link or create Contact if People feature is enabled
siteSettingsService.get().then(async (s) => {
if (!s.enablePeople) return;
// Check for existing Contact with matching email → link
const existingContact = await prisma.contact.findFirst({
where: { email: { equals: data.email, mode: 'insensitive' }, userId: null, mergedIntoId: null },
});
if (existingContact) {
await prisma.contact.update({ where: { id: existingContact.id }, data: { userId: user.id } });
logger.info(`Auto-linked contact ${existingContact.id} to registered user ${user.id}`);
} else {
// Create new Contact linked to the user
await prisma.contact.create({
data: {
displayName: data.name || data.email,
firstName: data.name?.split(' ')[0] || null,
lastName: data.name?.split(' ').slice(1).join(' ') || null,
email: data.email,
phone: data.phone || null,
primarySource: 'USER',
userId: user.id,
tags: [],
},
});
logger.info(`Auto-created contact for registered user ${user.id}`);
}
}).catch(err => {
logger.warn('Contact auto-creation on register failed:', err);
});
// If verification required, send email and don't issue tokens
if (requireVerification) {
const token = await verificationTokenService.createToken(user.id);

View File

@@ -17,6 +17,10 @@ import {
getRecentComments,
getUpcomingShifts,
getRecentSignups,
getGiteaActivity,
getRocketChatStats,
getVaultwardenAdoption,
getListmonkCampaigns,
} from './dashboard.service';
const router = Router();
@@ -212,4 +216,44 @@ router.get('/recent-comments', async (_req: Request, res: Response, next: NextFu
}
});
// GET /api/dashboard/gitea-activity — Gitea repos, users, recent commits (SUPER_ADMIN only)
router.get('/gitea-activity', requireRole('SUPER_ADMIN'), async (_req: Request, res: Response, next: NextFunction) => {
try {
const data = await getGiteaActivity();
res.json(data);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/rocketchat-stats — RC users, channels, messages (SUPER_ADMIN only)
router.get('/rocketchat-stats', requireRole('SUPER_ADMIN'), async (_req: Request, res: Response, next: NextFunction) => {
try {
const data = await getRocketChatStats();
res.json(data);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/vaultwarden-adoption — VW user adoption stats (SUPER_ADMIN only)
router.get('/vaultwarden-adoption', requireRole('SUPER_ADMIN'), async (_req: Request, res: Response, next: NextFunction) => {
try {
const data = await getVaultwardenAdoption();
res.json(data);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/listmonk-campaigns — Listmonk campaign performance (SUPER_ADMIN only)
router.get('/listmonk-campaigns', requireRole('SUPER_ADMIN'), async (_req: Request, res: Response, next: NextFunction) => {
try {
const data = await getListmonkCampaigns();
res.json(data);
} catch (err) {
next(err);
}
});
export const dashboardRouter = router;

View File

@@ -10,7 +10,10 @@ import { isServiceOnline } from '../../utils/health-check';
import { listmonkClient } from '../../services/listmonk.client';
import { gancioClient } from '../../services/gancio.client';
import { rocketchatClient } from '../../services/rocketchat.client';
import { giteaClient } from '../../services/gitea.client';
import { vaultwardenClient } from '../../services/vaultwarden.client';
import { emailService } from '../../services/email.service';
import { redis } from '../../config/redis';
import { logger } from '../../utils/logger';
// --- Types ---
@@ -75,6 +78,9 @@ export interface DashboardSummary {
campaignModeration: {
pendingReview: number;
};
docsComments: {
pending: number;
};
}
export interface SystemInfo {
@@ -134,21 +140,41 @@ export interface WeatherData {
// --- Container definitions ---
const CONTAINERS: { name: string; label: string }[] = [
// Core
{ name: 'changemaker-v2-api', label: 'API' },
{ name: 'changemaker-media-api', label: 'Media API' },
{ name: 'changemaker-v2-admin', label: 'Admin' },
{ name: 'changemaker-v2-postgres', label: 'PostgreSQL' },
{ name: 'redis-changemaker', label: 'Redis' },
{ name: 'changemaker-v2-nginx', label: 'Nginx' },
// Services
{ name: 'changemaker-v2-nocodb', label: 'NocoDB' },
{ name: 'listmonk-app', label: 'Listmonk' },
{ name: 'listmonk-db', label: 'Listmonk DB' },
{ name: 'n8n-changemaker', label: 'n8n' },
{ name: 'homepage-changemaker', label: 'Homepage' },
{ name: 'gitea-changemaker', label: 'Gitea' },
{ name: 'gitea-mysql', label: 'Gitea MySQL' },
{ name: 'mailhog-changemaker', label: 'MailHog' },
{ name: 'mini-qr', label: 'Mini QR' },
{ name: 'excalidraw-changemaker', label: 'Excalidraw' },
{ name: 'code-server-changemaker', label: 'Code Server' },
{ name: 'mkdocs-changemaker', label: 'MkDocs' },
{ name: 'mkdocs-site-server-changemaker', label: 'MkDocs Site' },
// Communication
{ name: 'rocketchat-changemaker', label: 'Rocket.Chat' },
{ name: 'mongodb-rocketchat', label: 'RC MongoDB' },
{ name: 'nats-rocketchat', label: 'RC NATS' },
{ name: 'vaultwarden-changemaker', label: 'Vaultwarden' },
{ name: 'gancio-changemaker', label: 'Gancio' },
// Jitsi Meet
{ name: 'jitsi-web-changemaker', label: 'Jitsi Web' },
{ name: 'jitsi-prosody-changemaker', label: 'Jitsi Prosody' },
{ name: 'jitsi-jicofo-changemaker', label: 'Jitsi Jicofo' },
{ name: 'jitsi-jvb-changemaker', label: 'Jitsi JVB' },
// Infrastructure
{ name: 'newt-changemaker', label: 'Newt Tunnel' },
{ name: 'docker-socket-proxy', label: 'Docker Proxy' },
];
// --- WMO weather code descriptions ---
@@ -204,6 +230,7 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
cutsTotal,
repsCached,
campaignsPendingReview,
docsCommentsPending,
] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { role: 'SUPER_ADMIN' } }),
@@ -242,6 +269,7 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
prisma.cut.count(),
prisma.representative.count(),
prisma.campaign.count({ where: { moderationStatus: 'PENDING_REVIEW' } }),
prisma.docsComment.count({ where: { status: 'PENDING' } }).catch(() => 0),
]);
return {
@@ -265,6 +293,7 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
cuts: { total: cutsTotal },
representatives: { totalCached: repsCached },
campaignModeration: { pendingReview: campaignsPendingReview },
docsComments: { pending: docsCommentsPending },
};
}
@@ -1162,3 +1191,201 @@ export async function getChatSummary(): Promise<ChatSummaryResult> {
return { enabled: true, messages: [], unreadChannels: 0 };
}
}
// --- Redis-Cached Service Dashboard Endpoints ---
/**
* Helper: get JSON from Redis cache or fetch from source, then cache with TTL.
*/
async function cachedFetch<T>(
key: string,
ttlSeconds: number,
fetcher: () => Promise<T>,
): Promise<T> {
try {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached) as T;
} catch {
// Redis unavailable, fall through to live fetch
}
const data = await fetcher();
try {
await redis.set(key, JSON.stringify(data), 'EX', ttlSeconds);
} catch {
// Redis unavailable, continue without caching
}
return data;
}
// --- Gitea Activity ---
export interface GiteaActivityResult {
repos: number;
users: number;
recentCommits: Array<{
repo: string;
message: string;
author: string;
date: string;
}>;
}
export async function getGiteaActivity(): Promise<GiteaActivityResult | { error: string }> {
try {
const available = await giteaClient.isAvailable();
if (!available) return { error: 'Service unavailable' };
return cachedFetch<GiteaActivityResult>('dashboard:gitea-activity', 300, async () => {
const [repos, users] = await Promise.all([
giteaClient.listRepos(50),
giteaClient.listAllUsers(100),
]);
// Fetch recent commits from the 5 most recently updated repos
const recentRepos = (Array.isArray(repos) ? repos : (repos as any).data || []).slice(0, 5);
const commitResults = await Promise.all(
recentRepos.map(async (r: any) => {
const parts = (r.full_name || '').split('/');
if (parts.length < 2) return [];
const commits = await giteaClient.listRepoCommits(parts[0], parts[1], 3);
return commits.map(c => ({
repo: r.name || r.full_name,
message: (c.commit?.message || '').split('\n')[0].slice(0, 80),
author: c.commit?.author?.name || 'unknown',
date: c.commit?.author?.date || '',
}));
}),
);
const recentCommits = commitResults
.flat()
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, 10);
const repoList = Array.isArray(repos) ? repos : (repos as any).data || [];
return {
repos: repoList.length,
users: users.length,
recentCommits,
};
});
} catch (err) {
logger.debug('Failed to fetch Gitea activity:', err);
return { error: 'Service unavailable' };
}
}
// --- Rocket.Chat Stats ---
export interface RocketChatStatsResult {
totalUsers: number;
onlineUsers: number;
channels: number;
totalMessages: number;
topChannels: Array<{ name: string; messages: number; users: number }>;
}
export async function getRocketChatStats(): Promise<RocketChatStatsResult | { error: string }> {
try {
const online = await rocketchatClient.healthCheck();
if (!online) return { error: 'Service unavailable' };
return cachedFetch<RocketChatStatsResult>('dashboard:rocketchat-stats', 180, async () => {
const [stats, channels] = await Promise.all([
rocketchatClient.getStatistics(),
rocketchatClient.listChannels(10),
]);
return {
totalUsers: stats?.totalUsers || 0,
onlineUsers: stats?.onlineUsers || 0,
channels: stats?.totalChannels || 0,
totalMessages: stats?.totalMessages || 0,
topChannels: channels.map(c => ({
name: c.name,
messages: c.msgs,
users: c.usersCount,
})),
};
});
} catch (err) {
logger.debug('Failed to fetch Rocket.Chat stats:', err);
return { error: 'Service unavailable' };
}
}
// --- Vaultwarden Adoption ---
export interface VaultwardenAdoptionResult {
total: number;
enabled: number;
invited: number;
disabled: number;
adoptionRate: number;
}
export async function getVaultwardenAdoption(): Promise<VaultwardenAdoptionResult | { error: string }> {
try {
if (!vaultwardenClient.hasCredentials) return { error: 'Service unavailable' };
return cachedFetch<VaultwardenAdoptionResult>('dashboard:vaultwarden-adoption', 600, async () => {
const users = await vaultwardenClient.listUsers();
const enabled = users.filter(u => u._Status === 0).length;
const invited = users.filter(u => u._Status === 1).length;
const disabled = users.filter(u => u._Status === 2).length;
const total = users.length;
const adoptionRate = total > 0 ? Math.round((enabled / total) * 100) : 0;
return { total, enabled, invited, disabled, adoptionRate };
});
} catch (err) {
logger.debug('Failed to fetch Vaultwarden adoption:', err);
return { error: 'Service unavailable' };
}
}
// --- Listmonk Campaigns ---
export interface ListmonkCampaignItem {
name: string;
status: string;
sentCount: number;
openRate: number;
clickRate: number;
}
export interface ListmonkCampaignsResult {
campaigns: ListmonkCampaignItem[];
}
export async function getListmonkCampaigns(): Promise<ListmonkCampaignsResult | { error: string }> {
try {
const healthy = await listmonkClient.checkHealth();
if (!healthy) return { error: 'Service unavailable' };
return cachedFetch<ListmonkCampaignsResult>('dashboard:listmonk-campaigns', 300, async () => {
const rawCampaigns = await listmonkClient.getCampaigns();
const campaigns: ListmonkCampaignItem[] = rawCampaigns.slice(0, 15).map(c => {
const openRate = c.sent > 0 ? Math.round((c.views / c.sent) * 100) : 0;
const clickRate = c.sent > 0 ? Math.round((c.clicks / c.sent) * 100) : 0;
return {
name: c.name,
status: c.status,
sentCount: c.sent,
openRate,
clickRate,
};
});
return { campaigns };
});
} catch (err) {
logger.debug('Failed to fetch Listmonk campaigns:', err);
return { error: 'Service unavailable' };
}
}

View File

@@ -0,0 +1,266 @@
import { Router } from 'express';
import { UserRole } from '@prisma/client';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import {
docsCommentAnonRateLimit,
docsCommentAuthRateLimit,
docsCommentFetchRateLimit,
} from '../../middleware/rate-limit';
import { docsCommentsService } from './docs-comments.service';
import { giteaClient } from '../../services/gitea.client';
import {
getCommentsSchema,
postAnonymousCommentSchema,
postAuthenticatedCommentSchema,
oauthExchangeSchema,
moderateCommentSchema,
moderationQuerySchema,
} from './docs-comments.schemas';
import { env } from '../../config/env';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
// --- Public Router (CORS override for docs origin) ---
export const docsCommentsPublicRouter = Router();
// Per-route CORS: MkDocs runs on a different origin (root domain vs API subdomain)
docsCommentsPublicRouter.use((_req, res, next) => {
// Allow both the docs origin and the admin app origin
const allowedOrigins = [
env.ADMIN_URL,
`https://${env.DOMAIN}`,
`https://docs.${env.DOMAIN}`,
`http://localhost:${env.MKDOCS_PORT}`,
];
const origin = _req.headers.origin;
if (origin && allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
res.setHeader('Vary', 'Origin');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Gitea-Token');
next();
});
// Handle preflight for all public routes
docsCommentsPublicRouter.options('*', (_req, res) => {
res.sendStatus(204);
});
// GET /api/docs-comments/comments?pagePath=...&page=1
docsCommentsPublicRouter.get(
'/comments',
docsCommentFetchRateLimit,
validate(getCommentsSchema, 'query'),
async (req, res, next) => {
try {
if (!(await giteaClient.isEnabled())) {
res.status(404).json({ error: 'Comments are not enabled' });
return;
}
const { pagePath, page } = req.query as { pagePath: string; page: string };
const result = await docsCommentsService.getPageComments(pagePath, Number(page) || 1);
res.json(result);
} catch (err) {
next(err);
}
},
);
// POST /api/docs-comments/comments/anonymous
docsCommentsPublicRouter.post(
'/comments/anonymous',
docsCommentAnonRateLimit,
validate(postAnonymousCommentSchema),
async (req, res, next) => {
try {
if (!(await giteaClient.isEnabled())) {
res.status(404).json({ error: 'Comments are not enabled' });
return;
}
// Honeypot check — if filled, silently accept but don't store
if (req.body.website) {
res.json({ id: 'ignored', status: 'pending' });
return;
}
const result = await docsCommentsService.postAnonymousComment({
pagePath: req.body.pagePath,
authorName: req.body.authorName,
authorEmail: req.body.authorEmail,
body: req.body.body,
});
res.status(201).json(result);
} catch (err) {
next(err);
}
},
);
// POST /api/docs-comments/comments/authenticated
docsCommentsPublicRouter.post(
'/comments/authenticated',
docsCommentAuthRateLimit,
validate(postAuthenticatedCommentSchema),
async (req, res, next) => {
try {
if (!(await giteaClient.isEnabled())) {
res.status(404).json({ error: 'Comments are not enabled' });
return;
}
const userToken = req.headers['x-gitea-token'] as string;
if (!userToken) {
res.status(401).json({ error: 'Missing X-Gitea-Token header' });
return;
}
const result = await docsCommentsService.postAuthenticatedComment({
pagePath: req.body.pagePath,
body: req.body.body,
userToken,
});
res.status(201).json(result);
} catch (err) {
next(err);
}
},
);
// POST /api/docs-comments/oauth/exchange
docsCommentsPublicRouter.post(
'/oauth/exchange',
docsCommentAuthRateLimit,
validate(oauthExchangeSchema),
async (req, res, next) => {
try {
if (!(await giteaClient.isOAuthEnabled())) {
res.status(404).json({ error: 'OAuth is not configured' });
return;
}
const tokenResponse = await giteaClient.exchangeOAuthCode(
req.body.code,
req.body.redirectUri,
);
if (!tokenResponse) {
res.status(400).json({ error: 'OAuth code exchange failed' });
return;
}
// Also fetch user info so the widget can display name/avatar
const user = await giteaClient.getAuthenticatedUser(tokenResponse.access_token);
res.json({
accessToken: tokenResponse.access_token,
user: user
? {
login: user.login,
name: user.full_name || user.login,
avatarUrl: user.avatar_url,
}
: null,
});
} catch (err) {
next(err);
}
},
);
// GET /api/docs-comments/oauth/config — public config for the widget
docsCommentsPublicRouter.get('/oauth/config', async (_req, res) => {
const enabled = await giteaClient.isEnabled();
if (!enabled) {
res.status(404).json({ error: 'Comments are not enabled' });
return;
}
const config = await giteaClient.getConfig();
const oauthEnabled = await giteaClient.isOAuthEnabled();
res.json({
enabled: true,
oauthEnabled,
clientId: config.oauthClientId || null,
authorizeUrl: oauthEnabled ? giteaClient.getAuthorizeUrl() : null,
});
});
// --- Admin Router (auth required) ---
export const docsCommentsAdminRouter = Router();
docsCommentsAdminRouter.use(authenticate);
docsCommentsAdminRouter.use(requireRole(...ADMIN_ROLES));
// GET /api/docs-comments/moderation?status=PENDING&page=1
docsCommentsAdminRouter.get(
'/moderation',
validate(moderationQuerySchema, 'query'),
async (req, res, next) => {
try {
const { status, page, pageSize } = req.query as {
status?: string;
page: string;
pageSize: string;
};
const result = await docsCommentsService.getModerationQueue({
status: status as any,
page: Number(page) || 1,
pageSize: Number(pageSize) || 20,
});
res.json(result);
} catch (err) {
next(err);
}
},
);
// POST /api/docs-comments/moderation/:id — approve or reject
docsCommentsAdminRouter.post(
'/moderation/:id',
validate(moderateCommentSchema),
async (req, res, next) => {
try {
const id = req.params.id as string;
const { action } = req.body;
const reviewedBy = req.user?.email || 'unknown';
const result = await docsCommentsService.moderateComment(id, action, reviewedBy);
res.json(result);
} catch (err) {
next(err);
}
},
);
// POST /api/docs-comments/setup — create repo + labels
docsCommentsAdminRouter.post('/setup', async (_req, res, next) => {
try {
if (!(await giteaClient.isEnabled())) {
res.status(400).json({ error: 'Gitea comments are not configured. Enable comments and set API token + repo owner in Settings > Web > Comments.' });
return;
}
const result = await docsCommentsService.setup();
res.json(result);
} catch (err) {
next(err);
}
});
// GET /api/docs-comments/stats
docsCommentsAdminRouter.get('/stats', async (_req, res, next) => {
try {
const stats = await docsCommentsService.getStats();
res.json(stats);
} catch (err) {
next(err);
}
});

View File

@@ -0,0 +1,35 @@
import { z } from 'zod';
export const getCommentsSchema = z.object({
pagePath: z.string().min(1).max(500),
page: z.coerce.number().int().min(1).default(1),
});
export const postAnonymousCommentSchema = z.object({
pagePath: z.string().min(1).max(500),
authorName: z.string().min(1).max(100).trim(),
authorEmail: z.string().email().max(255).optional(),
body: z.string().min(10).max(5000).trim(),
// Honeypot — should be empty if submitted by a human
website: z.string().max(0).optional(),
});
export const postAuthenticatedCommentSchema = z.object({
pagePath: z.string().min(1).max(500),
body: z.string().min(10).max(5000).trim(),
});
export const oauthExchangeSchema = z.object({
code: z.string().min(1).max(512),
redirectUri: z.string().url().max(1000),
});
export const moderateCommentSchema = z.object({
action: z.enum(['approve', 'reject']),
});
export const moderationQuerySchema = z.object({
status: z.enum(['PENDING', 'APPROVED', 'REJECTED']).optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});

View File

@@ -0,0 +1,331 @@
import { DocsCommentStatus } from '@prisma/client';
import { prisma } from '../../config/database';
import { redis } from '../../config/redis';
import { logger } from '../../utils/logger';
import { giteaClient } from '../../services/gitea.client';
const ISSUE_CACHE_PREFIX = 'docs-comment-issue:';
const ISSUE_CACHE_TTL = 300; // 5 minutes
// HTML comment prefix used to identify anonymous comments in Gitea
const ANON_PREFIX_RE = /^<!-- docs-comment: anonymous \| name: (.+?) \| id: (\S+) -->\n/;
function buildAnonPrefix(authorName: string, id: string): string {
return `<!-- docs-comment: anonymous | name: ${authorName} | id: ${id} -->\n`;
}
function pagePathToIssueTitle(pagePath: string): string {
// Normalize: strip leading/trailing slashes, default to "index"
const normalized = pagePath.replace(/^\/+|\/+$/g, '') || 'index';
return `Comments: ${normalized}`;
}
export const docsCommentsService = {
/**
* Get or create the Gitea issue for a given page path.
* Uses Redis cache to avoid repeated Gitea API calls.
*/
async getOrCreateIssue(pagePath: string): Promise<number | null> {
const title = pagePathToIssueTitle(pagePath);
const cacheKey = `${ISSUE_CACHE_PREFIX}${title}`;
// Check Redis cache
const cached = await redis.get(cacheKey).catch(() => null);
if (cached) return parseInt(cached, 10);
// Search Gitea
const existing = await giteaClient.findIssueByTitle(title);
if (existing) {
await redis.setex(cacheKey, ISSUE_CACHE_TTL, existing.number.toString()).catch(() => {});
return existing.number;
}
// Create new issue
const issue = await giteaClient.createIssue(
title,
`Comments for documentation page: \`${pagePath}\`\n\n_This issue is managed automatically by Changemaker Lite._`,
);
if (!issue) return null;
await redis.setex(cacheKey, ISSUE_CACHE_TTL, issue.number.toString()).catch(() => {});
return issue.number;
},
/**
* Get approved comments for a page.
* Returns both authenticated Gitea comments and approved anonymous comments.
*/
async getPageComments(pagePath: string, page: number = 1) {
const title = pagePathToIssueTitle(pagePath);
// Find the issue (don't create if it doesn't exist — no comments yet)
const existing = await giteaClient.findIssueByTitle(title);
if (!existing) {
return { comments: [], total: 0, page };
}
// Fetch all comments from Gitea
const giteaComments = await giteaClient.listIssueComments(existing.number);
// Get approved anonymous comment IDs from DB
const approvedAnon = await prisma.docsComment.findMany({
where: {
pagePath,
giteaIssueNumber: existing.number,
status: DocsCommentStatus.APPROVED,
},
select: { giteaCommentId: true, authorName: true },
});
const approvedAnonIds = new Set(approvedAnon.map((a) => a.giteaCommentId));
const anonNameMap = new Map(approvedAnon.map((a) => [a.giteaCommentId, a.authorName]));
// Filter and transform comments
const comments = giteaComments
.map((gc) => {
const anonMatch = gc.body.match(ANON_PREFIX_RE);
if (anonMatch) {
// Anonymous comment — only include if approved
if (!approvedAnonIds.has(BigInt(gc.id))) return null;
return {
id: gc.id,
body: gc.body.replace(ANON_PREFIX_RE, ''),
authorName: anonNameMap.get(BigInt(gc.id)) || anonMatch[1],
avatarUrl: null,
isAnonymous: true,
createdAt: gc.created_at,
};
}
// Authenticated comment — always show
return {
id: gc.id,
body: gc.body,
authorName: gc.user.full_name || gc.user.login,
avatarUrl: gc.user.avatar_url,
isAnonymous: false,
createdAt: gc.created_at,
};
})
.filter(Boolean);
// Simple pagination (Gitea already limits to 50)
const pageSize = 20;
const start = (page - 1) * pageSize;
const paginated = comments.slice(start, start + pageSize);
return { comments: paginated, total: comments.length, page };
},
/**
* Post an anonymous comment. Creates a DocsComment row for moderation.
*/
async postAnonymousComment(params: {
pagePath: string;
authorName: string;
authorEmail?: string;
body: string;
}) {
const issueNumber = await this.getOrCreateIssue(params.pagePath);
if (!issueNumber) {
throw new Error('Failed to create or find Gitea issue for this page');
}
// Create the DB row first to get the ID
const dbComment = await prisma.docsComment.create({
data: {
pagePath: params.pagePath,
giteaIssueNumber: issueNumber,
giteaCommentId: BigInt(0), // Will be updated after Gitea post
authorName: params.authorName,
authorEmail: params.authorEmail,
status: DocsCommentStatus.PENDING,
},
});
// Post to Gitea with metadata prefix
const prefix = buildAnonPrefix(params.authorName, dbComment.id);
const giteaComment = await giteaClient.createIssueComment(
issueNumber,
`${prefix}${params.body}`,
);
if (!giteaComment) {
// Clean up DB row if Gitea post failed
await prisma.docsComment.delete({ where: { id: dbComment.id } }).catch(() => {});
throw new Error('Failed to post comment to Gitea');
}
// Update DB row with actual Gitea comment ID
await prisma.docsComment.update({
where: { id: dbComment.id },
data: { giteaCommentId: BigInt(giteaComment.id) },
});
logger.info(`Docs comment: anonymous comment by "${params.authorName}" on ${params.pagePath} (pending moderation)`);
return { id: dbComment.id, status: 'pending' };
},
/**
* Post an authenticated comment via user's Gitea OAuth token.
* No moderation needed — appears immediately.
*/
async postAuthenticatedComment(params: {
pagePath: string;
body: string;
userToken: string;
}) {
const issueNumber = await this.getOrCreateIssue(params.pagePath);
if (!issueNumber) {
throw new Error('Failed to create or find Gitea issue for this page');
}
const giteaComment = await giteaClient.createIssueComment(
issueNumber,
params.body,
params.userToken,
);
if (!giteaComment) {
throw new Error('Failed to post comment to Gitea');
}
logger.info(`Docs comment: authenticated comment by "${giteaComment.user.login}" on ${params.pagePath}`);
return {
id: giteaComment.id,
body: giteaComment.body,
authorName: giteaComment.user.full_name || giteaComment.user.login,
avatarUrl: giteaComment.user.avatar_url,
isAnonymous: false,
createdAt: giteaComment.created_at,
};
},
/**
* Moderation: approve or reject an anonymous comment
*/
async moderateComment(commentId: string, action: 'approve' | 'reject', reviewedBy: string) {
const comment = await prisma.docsComment.findUnique({ where: { id: commentId } });
if (!comment) throw new Error('Comment not found');
if (action === 'reject') {
// Delete from Gitea + update DB
if (comment.giteaCommentId > 0) {
await giteaClient.deleteIssueComment(Number(comment.giteaCommentId));
}
await prisma.docsComment.update({
where: { id: commentId },
data: {
status: DocsCommentStatus.REJECTED,
reviewedAt: new Date(),
reviewedBy,
},
});
logger.info(`Docs comment: rejected comment ${commentId} by "${comment.authorName}"`);
return { status: 'rejected' };
}
// Approve
await prisma.docsComment.update({
where: { id: commentId },
data: {
status: DocsCommentStatus.APPROVED,
reviewedAt: new Date(),
reviewedBy,
},
});
logger.info(`Docs comment: approved comment ${commentId} by "${comment.authorName}"`);
return { status: 'approved' };
},
/**
* Get moderation queue
*/
async getModerationQueue(params: {
status?: DocsCommentStatus;
page: number;
pageSize: number;
}) {
const where = params.status ? { status: params.status } : {};
const [items, total] = await Promise.all([
prisma.docsComment.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (params.page - 1) * params.pageSize,
take: params.pageSize,
}),
prisma.docsComment.count({ where }),
]);
// Fetch comment body from Gitea for each item
const enriched = await Promise.all(
items.map(async (item) => {
let body = '';
if (item.giteaCommentId > 0) {
try {
const comments = await giteaClient.listIssueComments(item.giteaIssueNumber);
const gc = comments.find((c) => c.id === Number(item.giteaCommentId));
if (gc) {
body = gc.body.replace(ANON_PREFIX_RE, '');
}
} catch {
body = '[Unable to fetch comment body]';
}
}
return {
id: item.id,
pagePath: item.pagePath,
authorName: item.authorName,
authorEmail: item.authorEmail,
body,
status: item.status,
reviewedAt: item.reviewedAt,
reviewedBy: item.reviewedBy,
createdAt: item.createdAt,
};
}),
);
return { items: enriched, total, page: params.page, pageSize: params.pageSize };
},
/**
* Get statistics for the admin dashboard
*/
async getStats() {
const [pending, approved, rejected, totalPages] = await Promise.all([
prisma.docsComment.count({ where: { status: DocsCommentStatus.PENDING } }),
prisma.docsComment.count({ where: { status: DocsCommentStatus.APPROVED } }),
prisma.docsComment.count({ where: { status: DocsCommentStatus.REJECTED } }),
prisma.docsComment.groupBy({
by: ['pagePath'],
_count: true,
}),
]);
return { pending, approved, rejected, totalPages: totalPages.length };
},
/**
* Admin setup: create the repo and labels in Gitea
*/
async setup() {
const repo = await giteaClient.createRepo();
if (!repo) throw new Error('Failed to create Gitea repository');
const labels = await Promise.all([
giteaClient.findOrCreateLabel('docs-page', '#0075ca'),
giteaClient.findOrCreateLabel('anonymous', '#e4e669'),
giteaClient.findOrCreateLabel('moderated', '#0e8a16'),
]);
return { repo: repo.full_name ?? `${repo.name}`, labels: labels.filter(Boolean).map((l) => l!.name) };
},
};

View File

@@ -281,6 +281,41 @@ async function invalidateTreeCache(): Promise<void> {
}
}
/**
* Flatten a FileNode tree into file-only entries, then filter by
* case-insensitive query match on name or path. Name matches score
* higher so they sort first.
*/
async function searchFiles(
query: string,
limit = 5,
): Promise<{ name: string; path: string }[]> {
const tree = await listTree();
const q = query.toLowerCase();
const matches: { name: string; path: string; score: number }[] = [];
function walk(nodes: FileNode[]) {
for (const node of nodes) {
if (node.isDirectory) {
if (node.children) walk(node.children);
} else {
const nameLower = node.name.toLowerCase();
const pathLower = node.path.toLowerCase();
if (nameLower.includes(q)) {
matches.push({ name: node.name, path: node.path, score: 2 });
} else if (pathLower.includes(q)) {
matches.push({ name: node.name, path: node.path, score: 1 });
}
}
}
}
walk(tree);
matches.sort((a, b) => b.score - a.score);
return matches.slice(0, limit).map(({ name, path }) => ({ name, path }));
}
export const docsFilesService = {
listTree,
readFileContent,
@@ -292,4 +327,5 @@ export const docsFilesService = {
safeResolve,
isEditableFile,
invalidateTreeCache,
searchFiles,
};

View File

@@ -221,6 +221,26 @@ router.get(
},
);
// GET /api/docs/files/search — search files by name/path (for command palette)
router.get(
'/files/search',
async (req: Request, res: Response, next: NextFunction) => {
try {
const search = String(req.query['search'] ?? req.query['q'] ?? '').trim();
if (!search) {
res.json({ files: [] });
return;
}
const limit = Math.min(Math.max(Number(req.query['limit']) || 5, 1), 20);
const files = await docsFilesService.searchFiles(search, limit);
res.json({ files });
} catch (err) {
logger.error('Failed to search docs files', err);
next(err);
}
},
);
// POST /api/docs/files/rename — rename/move file
router.post(
'/files/rename',

View File

@@ -12,7 +12,7 @@ export const headerNavItemSchema = z.object({
});
export const headerStyleSchema = z.object({
backgroundColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Must be a hex color'),
backgroundColor: z.string().max(500),
textColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Must be a hex color'),
hoverColor: z.string().max(100),
height: z.string().regex(/^\d+px$/, 'Must be in px format'),

View File

@@ -45,6 +45,39 @@ function escapeHtml(str: string): string {
.replace(/'/g, '&#39;');
}
/** Map Ant Design icon IDs to Material Icons Outlined names for MkDocs */
const ANT_ICON_TO_MATERIAL: Record<string, string> = {
HomeOutlined: 'home',
SendOutlined: 'send',
EnvironmentOutlined: 'place',
CalendarOutlined: 'event',
PlayCircleOutlined: 'play_circle',
HeartOutlined: 'favorite_border',
DollarOutlined: 'attach_money',
ShoppingOutlined: 'shopping_bag',
LinkOutlined: 'link',
GlobalOutlined: 'language',
BookOutlined: 'menu_book',
};
interface NavConfigItem {
id: string;
label: string;
path: string;
icon: string;
enabled: boolean;
order: number;
type: 'builtin' | 'custom';
featureFlag?: string;
external?: boolean;
}
/** Extended nav item with rendering hints (not persisted in schema) */
interface RenderNavItem extends HeaderNavItem {
/** When true, path is rendered as direct href (not rewritten via data-path JS) */
isAbsoluteHref?: boolean;
}
class HeaderBuilderService {
/**
* Read the current header config from disk.
@@ -100,30 +133,60 @@ class HeaderBuilderService {
/**
* Generate the Jinja2 main.html template from config.
* Mirrors the PublicNavBar style: 56px gradient bar, left brand, right nav links.
*/
generateMainHtml(config: HeaderConfig): string {
generateMainHtml(config: HeaderConfig | { enabled: boolean; items: RenderNavItem[]; style: HeaderConfig['style'] & { colorBgBase?: string; colorBgContainer?: string } }): string {
const enabledItems = config.items
.filter((item) => item.enabled)
.sort((a, b) => a.order - b.order);
const links = enabledItems.map((item) => this.renderNavLink(item)).join('\n ');
const links = enabledItems.map((item) => this.renderNavLink(item)).join('\n ');
const { backgroundColor, textColor, hoverColor, height } = config.style;
const { backgroundColor, textColor } = config.style;
const colorBgBase = ('colorBgBase' in config.style ? config.style.colorBgBase : undefined) || '#0d1b2a';
const colorBgContainer = ('colorBgContainer' in config.style ? config.style.colorBgContainer : undefined) || '#1b2838';
return `{# Auto-generated by Changemaker Lite Header Builder — do not edit manually #}
{% extends "base.html" %}
{% block announce %}
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet">
<nav class="cm-header-nav" role="navigation" aria-label="Application">
<div class="cm-header-nav__inner">
${links}
<div class="cm-header-nav__brand">
<a href="#" data-path="/home" class="cm-header-nav__brand-link">
<span class="cm-header-nav__brand-text">{{ config.site_name }}</span>
</a>
</div>
<div class="cm-header-nav__links">
<div class="cm-header-nav__links-inner">
${links}
<a href="#" data-path="/app" class="cm-header-nav__link">
<span class="material-icons-outlined">dashboard</span>
<span class="cm-header-nav__label">Admin</span>
</a>
</div>
<button class="cm-header-nav__hamburger" aria-label="Open navigation menu">
<span class="material-icons-outlined">menu</span>
</button>
</div>
</nav>
<div class="cm-header-nav__mobile-drawer" id="cm-mobile-drawer">
<div class="cm-header-nav__mobile-header">
<span class="cm-header-nav__brand-text">{{ config.site_name }}</span>
<button class="cm-header-nav__mobile-close" aria-label="Close navigation menu">
<span class="material-icons-outlined">close</span>
</button>
</div>
<div class="cm-header-nav__mobile-links">
${enabledItems.map((item) => this.renderMobileNavLink(item)).join('\n ')}
<a href="#" data-path="/app" class="cm-header-nav__mobile-link">
<span class="material-icons-outlined">dashboard</span>
<span>Admin</span>
</a>
</div>
</div>
<div class="cm-header-nav__mobile-overlay" id="cm-mobile-overlay"></div>
<script>
// Resolve nav link hrefs based on the current browser hostname.
// localhost → http://localhost:{ADMIN_PORT}
// subdomain.example.org → {proto}://app.example.org
(function() {
var h = location.hostname;
var base;
@@ -135,76 +198,188 @@ class HeaderBuilderService {
else { parts.unshift('app'); }
base = location.protocol + '//' + parts.join('.');
}
var links = document.querySelectorAll('.cm-header-nav__link[data-path]');
var links = document.querySelectorAll('[data-path]');
for (var i = 0; i < links.length; i++) {
links[i].setAttribute('href', base + links[i].getAttribute('data-path'));
}
// Highlight active nav link based on current path
var path = location.pathname;
var activeLink = null;
if (path.indexOf('/docs') === 0) activeLink = 'docs';
document.querySelectorAll('.cm-header-nav__link[data-nav-id], .cm-header-nav__mobile-link[data-nav-id]').forEach(function(el) {
if (el.getAttribute('data-nav-id') === activeLink) {
el.classList.add('cm-header-nav__link--active');
}
});
// Hamburger toggle
var hamburger = document.querySelector('.cm-header-nav__hamburger');
var drawer = document.getElementById('cm-mobile-drawer');
var overlay = document.getElementById('cm-mobile-overlay');
var closeBtn = document.querySelector('.cm-header-nav__mobile-close');
function openDrawer() { drawer.classList.add('open'); overlay.classList.add('open'); }
function closeDrawer() { drawer.classList.remove('open'); overlay.classList.remove('open'); }
if (hamburger) hamburger.addEventListener('click', openDrawer);
if (closeBtn) closeBtn.addEventListener('click', closeDrawer);
if (overlay) overlay.addEventListener('click', closeDrawer);
})();
</script>
<style>
/* Override MkDocs Material announce bar container */
.md-banner {
background: ${escapeHtml(backgroundColor)} !important;
color: ${escapeHtml(textColor)} !important;
padding: 0 !important;
}
/* Hide the dismiss (X) button that Material adds for announce.dismiss */
.md-banner__button {
display: none !important;
}
.cm-header-nav {
background: ${escapeHtml(backgroundColor)};
min-height: ${escapeHtml(height)};
height: 56px;
display: flex;
align-items: center;
justify-content: center;
justify-content: space-between;
padding: 0 24px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
z-index: 10;
box-sizing: border-box;
}
.cm-header-nav__inner {
.cm-header-nav a {
color: rgba(255, 255, 255, 0.85) !important;
}
.cm-header-nav__brand-link {
display: flex;
align-items: center;
gap: 6px;
max-width: 1400px;
width: 100%;
justify-content: center;
flex-wrap: nowrap;
overflow-x: auto;
gap: 10px;
text-decoration: none !important;
color: #fff !important;
}
.cm-header-nav__brand-text {
font-size: 18px;
font-weight: 600;
color: #fff !important;
}
.cm-header-nav__links {
display: flex;
align-items: center;
}
.cm-header-nav__links-inner {
display: flex;
align-items: center;
gap: 16px;
}
.cm-header-nav__link {
color: rgba(255, 255, 255, 0.85) !important;
text-decoration: none !important;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
color: ${escapeHtml(textColor)};
text-decoration: none;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
border-radius: 6px;
background: rgba(255, 255, 255, 0.12);
transition: background 0.15s, transform 0.1s;
font-size: 14px;
transition: color 0.2s, border-color 0.2s;
white-space: nowrap;
line-height: 1;
padding-bottom: 2px;
border-bottom: 2px solid transparent;
}
.cm-header-nav__link:hover {
background: ${escapeHtml(hoverColor)};
color: ${escapeHtml(textColor)};
text-decoration: none;
transform: translateY(-1px);
color: #fff !important;
text-decoration: none !important;
}
.cm-header-nav__link:active {
transform: translateY(0);
.cm-header-nav__link--active,
.cm-header-nav__link--active:hover {
color: #fff !important;
font-weight: 600;
border-bottom-color: #fff;
}
.cm-header-nav__link .material-icons {
.cm-header-nav__link .material-icons-outlined {
font-size: 16px;
opacity: 0.9;
}
.cm-header-nav__hamburger {
display: none;
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
color: #fff;
}
.cm-header-nav__hamburger .material-icons-outlined {
font-size: 24px;
}
/* Mobile drawer */
.cm-header-nav__mobile-drawer {
position: fixed;
top: 0;
right: -280px;
width: 280px;
height: 100vh;
background: ${escapeHtml(colorBgBase)};
z-index: 10001;
transition: right 0.3s ease;
display: flex;
flex-direction: column;
}
.cm-header-nav__mobile-drawer.open {
right: 0;
}
.cm-header-nav__mobile-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid rgba(255,255,255,0.1);
background: ${escapeHtml(colorBgContainer)};
}
.cm-header-nav__mobile-close {
background: none;
border: none;
cursor: pointer;
color: rgba(255,255,255,0.85);
padding: 4px;
}
.cm-header-nav__mobile-links {
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 0;
}
.cm-header-nav__mobile-link {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 24px;
color: rgba(255,255,255,0.85) !important;
text-decoration: none !important;
font-size: 15px;
border-radius: 4px;
}
.cm-header-nav__mobile-link:hover {
background: rgba(255,255,255,0.1);
color: #fff !important;
text-decoration: none !important;
}
.cm-header-nav__mobile-link--active {
color: #fff !important;
font-weight: 600;
background: rgba(255,255,255,0.1);
}
.cm-header-nav__mobile-link .material-icons-outlined {
font-size: 18px;
}
.cm-header-nav__mobile-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 10000;
}
.cm-header-nav__mobile-overlay.open {
display: block;
}
@media (max-width: 768px) {
.cm-header-nav { padding: 0 8px; }
.cm-header-nav__label { display: none; }
.cm-header-nav__link { padding: 8px 10px; }
.cm-header-nav__inner { gap: 4px; }
.cm-header-nav { padding: 0 16px; }
.cm-header-nav__links-inner { display: none; }
.cm-header-nav__hamburger { display: block; }
}
</style>
{% endblock %}
@@ -212,28 +387,103 @@ class HeaderBuilderService {
}
/**
* Render a single nav link element.
* Regenerate main.html from the centralized navConfig stored in SiteSettings.
* Called when navConfig changes via the settings API.
*/
private renderNavLink(item: HeaderNavItem): string {
const isAbsolute = item.path.startsWith('http://') || item.path.startsWith('https://');
async regenerateFromNavConfig(navConfigItems: NavConfigItem[], settings?: { publicHeaderGradient?: string; publicColorBgBase?: string; publicColorBgContainer?: string }): Promise<void> {
try {
const enabledItems = navConfigItems
.filter((item) => item.enabled)
.sort((a, b) => a.order - b.order);
if (enabledItems.length === 0) {
// Write minimal passthrough
const passthrough = '{# Auto-generated by Changemaker Lite Header Builder — header disabled #}\n{% extends "base.html" %}\n';
await writeFile(MAIN_HTML_PATH, passthrough, 'utf-8');
logger.info('Generated passthrough main.html (no nav items enabled)');
return;
}
// Convert NavConfigItems to HeaderNavItems for rendering
// Resolve $token paths: MkDocs serves at the root domain, so use relative paths
const headerItems: RenderNavItem[] = enabledItems.map((item) => {
let resolvedPath = item.path;
if (item.path === '$landing') resolvedPath = '/';
else if (item.path === '$docs') resolvedPath = '/docs/';
return {
id: item.id,
label: item.label,
path: resolvedPath,
icon: ANT_ICON_TO_MATERIAL[item.icon] || item.icon,
enabled: true,
order: item.order,
type: item.type,
// $token-resolved items use direct href (relative to MkDocs root), not data-path
openInNewTab: item.path.startsWith('$') ? false : item.external,
isAbsoluteHref: item.path.startsWith('$'),
};
});
const backgroundColor = settings?.publicHeaderGradient || 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)';
const colorBgBase = settings?.publicColorBgBase || '#0d1b2a';
const colorBgContainer = settings?.publicColorBgContainer || '#1b2838';
const config = {
enabled: true,
items: headerItems,
style: {
backgroundColor,
textColor: '#ffffff',
hoverColor: 'rgba(255,255,255,0.15)',
height: '40px',
colorBgBase,
colorBgContainer,
},
};
const html = this.generateMainHtml(config);
await writeFile(MAIN_HTML_PATH, html, 'utf-8');
logger.info('Regenerated main.html from navConfig');
} catch (err) {
logger.error('Failed to regenerate main.html from navConfig', err);
}
}
/**
* Render a single nav link element.
* Items with isAbsoluteHref use direct href (e.g. $token-resolved paths like / or /docs/).
*/
private renderNavLink(item: RenderNavItem): string {
const isAbsolute = item.isAbsoluteHref || item.path.startsWith('http://') || item.path.startsWith('https://');
const target = item.openInNewTab ? ' target="_blank" rel="noopener noreferrer"' : '';
const navId = item.id ? ` data-nav-id="${escapeHtml(item.id)}"` : '';
const iconHtml = item.icon
? `<span class="material-icons">${escapeHtml(item.icon)}</span>`
? `<span class="material-icons-outlined">${escapeHtml(item.icon)}</span>`
: '';
if (isAbsolute) {
// Absolute URLs: use href directly, no data-path (JS won't touch these)
return `<a href="${escapeHtml(item.path)}" class="cm-header-nav__link"${target}>
${iconHtml}
<span class="cm-header-nav__label">${escapeHtml(item.label)}</span>
</a>`;
return `<a href="${escapeHtml(item.path)}" class="cm-header-nav__link"${navId}${target}>${iconHtml}<span class="cm-header-nav__label">${escapeHtml(item.label)}</span></a>`;
}
// Relative paths: use data-path for JS resolution, href="#" as fallback
return `<a href="#" data-path="${escapeHtml(item.path)}" class="cm-header-nav__link"${target}>
${iconHtml}
<span class="cm-header-nav__label">${escapeHtml(item.label)}</span>
</a>`;
return `<a href="#" data-path="${escapeHtml(item.path)}" class="cm-header-nav__link"${navId}${target}>${iconHtml}<span class="cm-header-nav__label">${escapeHtml(item.label)}</span></a>`;
}
/**
* Render a single mobile drawer nav link.
*/
private renderMobileNavLink(item: RenderNavItem): string {
const isAbsolute = item.isAbsoluteHref || item.path.startsWith('http://') || item.path.startsWith('https://');
const target = item.openInNewTab ? ' target="_blank" rel="noopener noreferrer"' : '';
const navId = item.id ? ` data-nav-id="${escapeHtml(item.id)}"` : '';
const iconHtml = item.icon
? `<span class="material-icons-outlined">${escapeHtml(item.icon)}</span>`
: '';
if (isAbsolute) {
return `<a href="${escapeHtml(item.path)}" class="cm-header-nav__mobile-link"${navId}${target}>${iconHtml}<span>${escapeHtml(item.label)}</span></a>`;
}
return `<a href="#" data-path="${escapeHtml(item.path)}" class="cm-header-nav__mobile-link"${navId}${target}>${iconHtml}<span>${escapeHtml(item.label)}</span></a>`;
}
}

View File

@@ -1,12 +1,9 @@
import { readFile, writeFile, copyFile } from 'fs/promises';
import { request as httpRequest } from 'http';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
import { parseDocument, Document } from 'yaml';
import type { ScalarTag } from 'yaml';
const DOCKER_SOCKET = '/var/run/docker.sock';
/**
* Custom YAML tag schema to preserve !!python/name: and !!python/object: tags.
* Without this, the yaml library would reject these custom tags.
@@ -79,123 +76,25 @@ async function writeConfig(content: string): Promise<void> {
}
/**
* Make a request to the Docker Engine API over Unix socket.
*/
function dockerRequest(
method: string,
path: string,
body?: unknown,
): Promise<{ statusCode: number; body: string }> {
return new Promise((resolve, reject) => {
const options = {
socketPath: DOCKER_SOCKET,
path,
method,
headers: body
? { 'Content-Type': 'application/json' }
: undefined,
};
const req = httpRequest(options, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
resolve({
statusCode: res.statusCode || 0,
body: Buffer.concat(chunks).toString(),
});
});
});
req.on('error', reject);
if (body) {
req.write(JSON.stringify(body));
}
req.end();
});
}
/**
* Read raw output from a Docker exec start stream.
* Docker multiplexes stdout/stderr with 8-byte headers.
*/
function demuxDockerStream(raw: Buffer): string {
const lines: string[] = [];
let offset = 0;
while (offset < raw.length) {
if (offset + 8 > raw.length) break;
// byte 0: stream type (1=stdout, 2=stderr)
const size = raw.readUInt32BE(offset + 4);
offset += 8;
if (offset + size > raw.length) {
lines.push(raw.subarray(offset).toString('utf-8'));
break;
}
lines.push(raw.subarray(offset, offset + size).toString('utf-8'));
offset += size;
}
return lines.join('');
}
/**
* Execute `mkdocs build` inside the running MkDocs container via Docker Engine API.
* Trigger `mkdocs build --clean` via the build trigger HTTP server
* running inside the MkDocs container on port 8001.
*/
async function triggerBuild(): Promise<{ success: boolean; output: string; duration: number }> {
const containerName = env.MKDOCS_CONTAINER_NAME;
const buildUrl = `${env.MKDOCS_PREVIEW_URL.replace(':8000', ':8001')}/build`;
const startTime = Date.now();
try {
// 1. Create exec instance
const execCreate = await dockerRequest(
'POST',
`/containers/${containerName}/exec`,
{
AttachStdout: true,
AttachStderr: true,
Cmd: ['mkdocs', 'build', '--clean'],
},
);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 130_000);
if (execCreate.statusCode !== 201) {
throw new Error(`Failed to create exec: ${execCreate.body}`);
}
const { Id: execId } = JSON.parse(execCreate.body);
// 2. Start exec and collect output
const execOutput = await new Promise<Buffer>((resolve, reject) => {
const options = {
socketPath: DOCKER_SOCKET,
path: `/exec/${execId}/start`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
};
const req = httpRequest(options, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => resolve(Buffer.concat(chunks)));
});
req.on('error', reject);
req.write(JSON.stringify({ Detach: false, Tty: false }));
req.end();
const response = await fetch(buildUrl, {
method: 'POST',
signal: controller.signal,
});
clearTimeout(timeout);
const output = demuxDockerStream(execOutput);
// 3. Check exit code
const execInspect = await dockerRequest('GET', `/exec/${execId}/json`);
const inspectData = JSON.parse(execInspect.body);
const exitCode = inspectData.ExitCode ?? -1;
const duration = Date.now() - startTime;
return {
success: exitCode === 0,
output: output || '(no output)',
duration,
};
const data = await response.json() as { success: boolean; output: string; duration: number };
return data;
} catch (err) {
const duration = Date.now() - startTime;
logger.error('MkDocs build failed', err);

View File

@@ -0,0 +1,224 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { redis } from '../../config/redis';
import { prisma } from '../../config/database';
import { env } from '../../config/env';
import { siteSettingsService } from '../settings/settings.service';
import { logger } from '../../utils/logger';
import { eventSubmissionRateLimit } from '../../middleware/rate-limit';
import { unifiedCalendarService } from './unified-calendar.service';
import { gancioClient } from '../../services/gancio.client';
const router = Router();
const CACHE_KEY = 'events:public-list';
const CACHE_TTL = 300; // 5 minutes
export interface PublicEvent {
id: number;
title: string;
description: string;
placeName: string;
placeAddress: string;
startDatetime: string;
endDatetime: string | null;
tags: string[];
shiftId: string | null;
}
// GET /api/events/public?limit=20&upcoming=true
router.get('/public', async (req: Request, res: Response, next: NextFunction) => {
try {
const settings = await siteSettingsService.getPublic();
if (!settings.enableEvents) {
res.json([]);
return;
}
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
const upcoming = req.query.upcoming !== 'false';
// Check cache
const cacheKey = `${CACHE_KEY}:${upcoming}:${limit}`;
try {
const cached = await redis.get(cacheKey);
if (cached) {
res.json(JSON.parse(cached));
return;
}
} catch { /* cache miss */ }
// Fetch from Gancio public API (no auth needed)
let rawEvents: Array<{
id: number;
title: string;
description: string;
place_name: string;
place_address: string;
start_datetime: number;
end_datetime?: number;
tags: string[];
}>;
try {
const url = `${env.GANCIO_URL}/api/events`;
const fetchRes = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!fetchRes.ok) {
res.json([]);
return;
}
rawEvents = await fetchRes.json() as typeof rawEvents;
} catch (err) {
logger.debug('Failed to fetch events from Gancio:', err);
res.json([]);
return;
}
const nowUnix = Math.floor(Date.now() / 1000);
let filtered = rawEvents;
if (upcoming) {
filtered = rawEvents.filter(e => e.start_datetime >= nowUnix);
}
filtered.sort((a, b) => a.start_datetime - b.start_datetime);
filtered = filtered.slice(0, limit);
// Batch-load matching shifts
const gancioIds = filtered.map(e => e.id);
const shifts = await prisma.shift.findMany({
where: { gancioEventId: { in: gancioIds } },
select: { id: true, gancioEventId: true },
});
const shiftMap = new Map(shifts.map(s => [s.gancioEventId!, s.id]));
const events: PublicEvent[] = filtered.map(e => ({
id: e.id,
title: e.title,
description: (e.description || '').slice(0, 300),
placeName: e.place_name || '',
placeAddress: e.place_address || '',
startDatetime: new Date(e.start_datetime * 1000).toISOString(),
endDatetime: e.end_datetime ? new Date(e.end_datetime * 1000).toISOString() : null,
tags: Array.isArray(e.tags) ? e.tags : [],
shiftId: shiftMap.get(e.id) ?? null,
}));
// Cache
try {
await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(events));
} catch { /* non-critical */ }
res.json(events);
} catch (err) {
next(err);
}
});
// --- Unified Calendar ---
const calendarQuerySchema = z.object({
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD'),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD'),
}).refine(data => {
const start = new Date(data.startDate);
const end = new Date(data.endDate);
const diffDays = (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24);
return diffDays >= 0 && diffDays <= 90;
}, { message: 'Date range must be 0-90 days' });
// GET /api/events/calendar?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD
router.get('/calendar', async (req: Request, res: Response, next: NextFunction) => {
try {
const settings = await siteSettingsService.getPublic();
if (!settings.enableEvents) {
res.json({ dates: {} });
return;
}
const parsed = calendarQuerySchema.safeParse(req.query);
if (!parsed.success) {
res.status(400).json({
error: { message: 'Invalid date range', code: 'VALIDATION_ERROR' },
});
return;
}
const result = await unifiedCalendarService.getCalendar(
parsed.data.startDate,
parsed.data.endDate,
);
res.json(result);
} catch (err) {
next(err);
}
});
// --- Event Submission (Proxy to Gancio) ---
const eventSubmitSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD'),
startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Must be HH:MM'),
endTime: z.string().regex(/^\d{2}:\d{2}$/, 'Must be HH:MM'),
location: z.string().max(500).optional(),
tags: z.array(z.string().max(50)).max(10).optional(),
});
// POST /api/events/submit
router.post('/submit', eventSubmissionRateLimit, async (req: Request, res: Response, next: NextFunction) => {
try {
const settings = await siteSettingsService.getPublic();
if (!settings.enableEvents) {
res.status(403).json({
error: { message: 'Events are not enabled', code: 'EVENTS_DISABLED' },
});
return;
}
if (!gancioClient.enabled) {
res.status(503).json({
error: { message: 'Event service is not configured', code: 'GANCIO_NOT_CONFIGURED' },
});
return;
}
const parsed = eventSubmitSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
error: { message: 'Invalid event data', code: 'VALIDATION_ERROR' },
});
return;
}
const { title, description, date, startTime, endTime, location, tags } = parsed.data;
// Ensure 'community' tag is always included
const eventTags = [...new Set([...(tags || []), 'community'])];
const gancioEventId = await gancioClient.createEvent({
title,
description,
location,
date: new Date(date),
startTime,
endTime,
tags: eventTags,
});
if (gancioEventId === null) {
res.status(502).json({
error: { message: 'Failed to create event in calendar service', code: 'GANCIO_ERROR' },
});
return;
}
// Bust calendar cache so the new event appears
await unifiedCalendarService.bustCache();
res.status(201).json({ success: true, gancioEventId });
} catch (err) {
next(err);
}
});
export { router as eventsListPublicRouter };

View File

@@ -0,0 +1,179 @@
import { ShiftStatus, SignupStatus } from '@prisma/client';
import { prisma } from '../../config/database';
import { redis } from '../../config/redis';
import { gancioClient, type GancioEvent } from '../../services/gancio.client';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
// --- Types ---
export interface UnifiedCalendarItem {
id: string;
type: 'shift' | 'event';
title: string;
date: string; // YYYY-MM-DD
startTime: string; // HH:MM
endTime: string; // HH:MM
location: string | null;
tags: string[];
// Shift-specific
shiftId?: string;
maxVolunteers?: number | null;
currentVolunteers?: number;
// Event-specific
gancioEventId?: number;
gancioUrl?: string;
}
export interface UnifiedCalendarResponse {
dates: Record<string, { count: number; items: UnifiedCalendarItem[] }>;
}
const CACHE_PREFIX = 'events:calendar';
const CACHE_TTL = 120; // 2 minutes
export const unifiedCalendarService = {
/**
* Fetch shifts + Gancio events in a date range, merge, deduplicate, group by date.
*/
async getCalendar(startDate: string, endDate: string): Promise<UnifiedCalendarResponse> {
const cacheKey = `${CACHE_PREFIX}:${startDate}:${endDate}`;
// Check cache
try {
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
} catch { /* cache miss */ }
const start = new Date(startDate);
const end = new Date(endDate);
// Set end to end of day
end.setHours(23, 59, 59, 999);
// Fetch shifts and Gancio events in parallel
const [shifts, gancioEvents] = await Promise.all([
this.fetchShifts(start, end),
this.fetchGancioEvents(start, end),
]);
// Build set of Gancio event IDs that correspond to synced shifts (to deduplicate)
const syncedGancioIds = new Set(
shifts
.filter(s => s.gancioEventId !== null)
.map(s => s.gancioEventId!),
);
// Normalize shifts into calendar items
const shiftItems: UnifiedCalendarItem[] = shifts.map(s => ({
id: `shift-${s.id}`,
type: 'shift',
title: s.title,
date: s.date.toISOString().split('T')[0],
startTime: s.startTime,
endTime: s.endTime,
location: s.location,
tags: ['volunteer', 'shift'],
shiftId: s.id,
maxVolunteers: s.maxVolunteers,
currentVolunteers: s._count?.signups ?? s.currentVolunteers,
}));
// Normalize Gancio events, excluding those already represented by a shift
const eventItems: UnifiedCalendarItem[] = gancioEvents
.filter(e => !syncedGancioIds.has(e.id))
.map(e => {
const startDt = new Date(e.start_datetime * 1000);
const endDt = e.end_datetime ? new Date(e.end_datetime * 1000) : startDt;
return {
id: `event-${e.id}`,
type: 'event' as const,
title: e.title,
date: startDt.toISOString().split('T')[0],
startTime: `${String(startDt.getHours()).padStart(2, '0')}:${String(startDt.getMinutes()).padStart(2, '0')}`,
endTime: `${String(endDt.getHours()).padStart(2, '0')}:${String(endDt.getMinutes()).padStart(2, '0')}`,
location: e.place_name || e.place_address || null,
tags: Array.isArray(e.tags) ? e.tags : [],
gancioEventId: e.id,
gancioUrl: `${env.GANCIO_URL}/event/${e.id}`,
};
});
// Merge and group by date
const allItems = [...shiftItems, ...eventItems];
allItems.sort((a, b) => a.startTime.localeCompare(b.startTime));
const dates: Record<string, { count: number; items: UnifiedCalendarItem[] }> = {};
for (const item of allItems) {
if (!dates[item.date]) {
dates[item.date] = { count: 0, items: [] };
}
dates[item.date].count++;
dates[item.date].items.push(item);
}
const result: UnifiedCalendarResponse = { dates };
// Cache
try {
await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(result));
} catch { /* non-critical */ }
return result;
},
/**
* Bust all calendar cache keys (called when shifts change).
*/
async bustCache(): Promise<void> {
try {
const keys = await redis.keys(`${CACHE_PREFIX}:*`);
if (keys.length > 0) {
await redis.del(...keys);
}
} catch (err) {
logger.debug('Calendar cache bust failed:', err);
}
},
// --- Private helpers ---
async fetchShifts(start: Date, end: Date) {
return prisma.shift.findMany({
where: {
isPublic: true,
status: { not: ShiftStatus.CANCELLED },
date: { gte: start, lte: end },
},
select: {
id: true,
title: true,
date: true,
startTime: true,
endTime: true,
location: true,
maxVolunteers: true,
currentVolunteers: true,
gancioEventId: true,
_count: {
select: { signups: { where: { status: SignupStatus.CONFIRMED } } },
},
},
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
});
},
async fetchGancioEvents(start: Date, end: Date): Promise<GancioEvent[]> {
try {
const events = await gancioClient.fetchPublicEvents();
const startUnix = Math.floor(start.getTime() / 1000);
const endUnix = Math.floor(end.getTime() / 1000);
return events.filter(e =>
e.start_datetime >= startUnix && e.start_datetime <= endUnix,
);
} catch (err) {
logger.debug('Failed to fetch Gancio events for calendar:', err);
return [];
}
},
};

View File

@@ -25,6 +25,21 @@ router.get(
}
);
// GET /api/gallery-ads/admin/analytics/aggregate — aggregate analytics across all ads
router.get(
'/analytics/aggregate',
validate(adAnalyticsQuerySchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { days } = req.query as any;
const analytics = await galleryAdsService.getAggregateAnalytics(days);
res.json(analytics);
} catch (err) {
next(err);
}
}
);
// GET /api/gallery-ads/admin/:id/analytics — per-ad time-series analytics
router.get(
'/:id/analytics',

View File

@@ -1,7 +1,7 @@
import { Router, Request, Response, NextFunction } from 'express';
import { prisma } from '../../config/database';
import { galleryAdsService } from './gallery-ads.service';
import { trackAdSchema } from './gallery-ads.schemas';
import { trackAdSchema, publicAdsQuerySchema } from './gallery-ads.schemas';
import { validate } from '../../middleware/validate';
import { optionalAuth } from '../../middleware/auth.middleware';
import { adTrackingRateLimit } from '../../middleware/rate-limit';
@@ -25,9 +25,13 @@ router.get('/', optionalAuth, async (req: Request, res: Response, next: NextFunc
hasActiveSubscription = !!sub;
}
const query = publicAdsQuerySchema.safeParse(req.query);
const placement = query.success ? query.data.placement : undefined;
const ads = await galleryAdsService.getActiveAds({
isAuthenticated,
hasActiveSubscription,
placement,
});
res.json(ads);

View File

@@ -1,5 +1,17 @@
import { z } from 'zod';
export const placementEnum = z.enum([
'gallery',
'campaigns_list',
'campaign_detail',
'shifts',
'shop',
'donate',
'pricing',
'landing_page',
'docs',
]);
export const createAdSchema = z.object({
type: z.enum(['system', 'payment_subscribe', 'payment_donate', 'payment_shop', 'custom']),
variant: z.enum(['standard', 'highlight', 'minimal']).optional().default('standard'),
@@ -17,6 +29,7 @@ export const createAdSchema = z.object({
frequency: z.number().int().min(1).max(24).optional().default(6),
startDate: z.string().datetime().optional().nullable(),
endDate: z.string().datetime().optional().nullable(),
placements: z.array(placementEnum).optional().default([]),
});
export const updateAdSchema = createAdSchema.partial();
@@ -28,6 +41,10 @@ export const listAdsSchema = z.object({
isActive: z.enum(['true', 'false']).optional(),
});
export const publicAdsQuerySchema = z.object({
placement: placementEnum.optional(),
});
export const reorderAdsSchema = z.object({
ids: z.array(z.number().int()).min(1),
});

View File

@@ -5,6 +5,7 @@ import type { CreateAdInput, UpdateAdInput, ListAdsInput } from './gallery-ads.s
interface ActiveAdsContext {
isAuthenticated: boolean;
hasActiveSubscription: boolean;
placement?: string;
}
class GalleryAdsService {
@@ -62,6 +63,7 @@ class GalleryAdsService {
frequency: data.frequency,
startDate: data.startDate ? new Date(data.startDate) : null,
endDate: data.endDate ? new Date(data.endDate) : null,
placements: data.placements ?? [],
isSystemAd: false,
},
});
@@ -95,6 +97,7 @@ class GalleryAdsService {
if (data.frequency !== undefined) updateData.frequency = data.frequency;
if (data.startDate !== undefined) updateData.startDate = data.startDate ? new Date(data.startDate) : null;
if (data.endDate !== undefined) updateData.endDate = data.endDate ? new Date(data.endDate) : null;
if (data.placements !== undefined) updateData.placements = data.placements;
updateData.updatedAt = new Date();
@@ -165,6 +168,14 @@ class GalleryAdsService {
if (!settings.enablePayments) return false;
}
// Placement filter: empty placements array = show on all pages
if (context.placement) {
const adPlacements = (ad.placements as string[]) || [];
if (adPlacements.length > 0 && !adPlacements.includes(context.placement)) {
return false;
}
}
return true;
});
}
@@ -293,6 +304,128 @@ class GalleryAdsService {
},
};
}
/**
* Admin: aggregate analytics across all ads.
* Returns daily breakdown, totals, top ads, and per-placement stats.
*/
async getAggregateAnalytics(days: number = 30) {
const since = new Date(Date.now() - days * 86400000);
const [dailyImpressions, dailyClicks, uniqueSessions, topAdsRaw] = await Promise.all([
prisma.$queryRaw<{ date: string; count: bigint }[]>`
SELECT DATE("created_at") as date, COUNT(*) as count
FROM ad_impressions
WHERE created_at >= ${since}
GROUP BY DATE("created_at")
ORDER BY date
`,
prisma.$queryRaw<{ date: string; count: bigint }[]>`
SELECT DATE("created_at") as date, COUNT(*) as count
FROM ad_clicks
WHERE created_at >= ${since}
GROUP BY DATE("created_at")
ORDER BY date
`,
prisma.adImpression.findMany({
where: { createdAt: { gte: since }, sessionId: { not: null } },
distinct: ['sessionId'],
select: { sessionId: true },
}),
prisma.$queryRaw<{ id: number; title: string; type: string; impressions: bigint; clicks: bigint }[]>`
SELECT a.id, a.title, a.type,
a.impression_count as impressions,
a.click_count as clicks
FROM ads a
ORDER BY a.impression_count DESC
LIMIT 10
`,
]);
// Merge daily impressions + clicks into a single array
const dateMap = new Map<string, { impressions: number; clicks: number }>();
for (const row of dailyImpressions) {
const dateStr = new Date(row.date).toISOString().split('T')[0];
const entry = dateMap.get(dateStr) || { impressions: 0, clicks: 0 };
entry.impressions = Number(row.count);
dateMap.set(dateStr, entry);
}
for (const row of dailyClicks) {
const dateStr = new Date(row.date).toISOString().split('T')[0];
const entry = dateMap.get(dateStr) || { impressions: 0, clicks: 0 };
entry.clicks = Number(row.count);
dateMap.set(dateStr, entry);
}
const daily = Array.from(dateMap.entries())
.map(([date, counts]) => ({ date, ...counts }))
.sort((a, b) => a.date.localeCompare(b.date));
// Totals from daily sums
const totalImpressions = daily.reduce((sum, d) => sum + d.impressions, 0);
const totalClicks = daily.reduce((sum, d) => sum + d.clicks, 0);
const ctr = totalImpressions > 0 ? Number(((totalClicks / totalImpressions) * 100).toFixed(1)) : 0;
// Top ads with CTR
const topAds = topAdsRaw.map((row) => {
const imp = Number(row.impressions);
const clk = Number(row.clicks);
return {
id: row.id,
title: row.title,
type: row.type,
impressions: imp,
clicks: clk,
ctr: imp > 0 ? Number(((clk / imp) * 100).toFixed(1)) : 0,
};
});
// Placement breakdown: fetch active ads with placements
const activeAds = await prisma.ad.findMany({
where: { isActive: true },
select: { placements: true, impressionCount: true, clickCount: true },
});
const placementMap = new Map<string, { adCount: number; impressions: number; clicks: number }>();
const placementLabels: Record<string, string> = {
gallery: 'Gallery',
campaigns_list: 'Campaigns List',
campaign_detail: 'Campaign Detail',
shifts: 'Shifts',
shop: 'Shop',
donate: 'Donate',
pricing: 'Pricing',
landing_page: 'Landing Pages',
docs: 'Documentation',
};
for (const ad of activeAds) {
const placements = (ad.placements as string[]) || [];
// Ads with no placements target all pages
const keys = placements.length > 0 ? placements : Object.keys(placementLabels);
for (const key of keys) {
const entry = placementMap.get(key) || { adCount: 0, impressions: 0, clicks: 0 };
entry.adCount++;
entry.impressions += ad.impressionCount ?? 0;
entry.clicks += ad.clickCount ?? 0;
placementMap.set(key, entry);
}
}
const byPlacement = Array.from(placementMap.entries()).map(([placement, stats]) => ({
placement,
label: placementLabels[placement] || placement,
...stats,
}));
return {
daily,
totals: { impressions: totalImpressions, clicks: totalClicks, uniqueSessions: uniqueSessions.length, ctr },
topAds,
byPlacement,
};
}
}
export const galleryAdsService = new GalleryAdsService();

View File

@@ -0,0 +1,16 @@
import { Router, Request, Response, NextFunction } from 'express';
import { homepageService } from './homepage.service';
const router = Router();
// GET /api/homepage — Aggregated public homepage data (no auth)
router.get('/', async (_req: Request, res: Response, next: NextFunction) => {
try {
const data = await homepageService.getPublicData();
res.json(data);
} catch (err) {
next(err);
}
});
export { router as homepageRouter };

View File

@@ -0,0 +1,243 @@
import { prisma } from '../../config/database';
import { redis } from '../../config/redis';
import { siteSettingsService } from '../settings/settings.service';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
const CACHE_KEY = 'homepage:public';
const CACHE_TTL = 120; // 2 minutes
interface HomepageData {
featuredCampaigns: Array<{
id: string;
slug: string;
title: string;
description: string | null;
emailCount: number;
highlightCampaign: boolean;
}>;
upcomingShifts: Array<{
id: string;
title: string;
startTime: string;
endTime: string;
location: string | null;
currentVolunteers: number;
maxVolunteers: number | null;
}>;
latestMedia: Array<{
id: number;
title: string | null;
thumbnailPath: string | null;
durationSeconds: number | null;
}>;
upcomingEvents: Array<{
id: number;
title: string;
placeName: string;
startDatetime: string;
tags: string[];
}>;
stats: {
totalEmailsSent: number;
activeCampaigns: number;
totalShiftSignups: number;
};
enabledModules: {
influence: boolean;
map: boolean;
media: boolean;
events: boolean;
payments: boolean;
landingPages: boolean;
};
}
export const homepageService = {
async getPublicData(): Promise<HomepageData> {
// Check cache
try {
const cached = await redis.get(CACHE_KEY);
if (cached) return JSON.parse(cached);
} catch { /* cache miss */ }
const settings = await siteSettingsService.getPublic();
const enabledModules = {
influence: settings.enableInfluence !== false,
map: settings.enableMap !== false,
media: settings.enableMediaFeatures !== false,
events: settings.enableEvents !== false,
payments: settings.enablePayments === true,
landingPages: settings.enableLandingPages !== false,
};
// Fan-out with allSettled so one failure doesn't break the whole page
const [
campaignsResult,
shiftsResult,
mediaResult,
eventsResult,
statsResult,
] = await Promise.allSettled([
enabledModules.influence ? this.getFeaturedCampaigns() : Promise.resolve([]),
enabledModules.map ? this.getUpcomingShifts() : Promise.resolve([]),
enabledModules.media ? this.getLatestMedia() : Promise.resolve([]),
enabledModules.events ? this.getUpcomingEvents() : Promise.resolve([]),
this.getStats(enabledModules),
]);
const data: HomepageData = {
featuredCampaigns: campaignsResult.status === 'fulfilled' ? campaignsResult.value : [],
upcomingShifts: shiftsResult.status === 'fulfilled' ? shiftsResult.value : [],
latestMedia: mediaResult.status === 'fulfilled' ? mediaResult.value : [],
upcomingEvents: eventsResult.status === 'fulfilled' ? eventsResult.value : [],
stats: statsResult.status === 'fulfilled' ? statsResult.value : { totalEmailsSent: 0, activeCampaigns: 0, totalShiftSignups: 0 },
enabledModules,
};
// Cache
try {
await redis.setex(CACHE_KEY, CACHE_TTL, JSON.stringify(data));
} catch { /* non-critical */ }
return data;
},
async getFeaturedCampaigns() {
// Highlighted first, then by email count
const campaigns = await prisma.campaign.findMany({
where: { status: 'ACTIVE' },
select: {
id: true,
slug: true,
title: true,
description: true,
highlightCampaign: true,
_count: { select: { emails: true } },
},
orderBy: [{ highlightCampaign: 'desc' }, { createdAt: 'desc' }],
take: 3,
});
return campaigns.map(c => ({
id: c.id,
slug: c.slug,
title: c.title,
description: c.description ? c.description.slice(0, 200) : null,
emailCount: c._count.emails,
highlightCampaign: c.highlightCampaign,
}));
},
async getUpcomingShifts() {
const today = new Date();
today.setHours(0, 0, 0, 0);
const shifts = await prisma.shift.findMany({
where: {
date: { gte: today },
status: 'OPEN',
},
select: {
id: true,
title: true,
date: true,
startTime: true,
endTime: true,
location: true,
maxVolunteers: true,
_count: { select: { signups: true } },
},
orderBy: { date: 'asc' },
take: 3,
});
return shifts.map(s => ({
id: s.id,
title: s.title,
startTime: `${s.date.toISOString().split('T')[0]}T${s.startTime}:00`,
endTime: `${s.date.toISOString().split('T')[0]}T${s.endTime}:00`,
location: s.location,
currentVolunteers: s._count.signups,
maxVolunteers: s.maxVolunteers,
}));
},
async getLatestMedia() {
try {
const videos = await prisma.video.findMany({
where: { isPublished: true },
select: {
id: true,
title: true,
thumbnailPath: true,
durationSeconds: true,
},
orderBy: { publishedAt: 'desc' },
take: 4,
});
return videos.map(v => ({
id: v.id,
title: v.title,
thumbnailPath: v.thumbnailPath,
durationSeconds: v.durationSeconds,
}));
} catch {
// Video table may not exist
return [];
}
},
async getUpcomingEvents() {
try {
const url = `${env.GANCIO_URL}/api/events`;
const fetchRes = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!fetchRes.ok) return [];
const rawEvents = await fetchRes.json() as Array<{
id: number;
title: string;
place_name: string;
start_datetime: number;
tags: string[];
}>;
const nowUnix = Math.floor(Date.now() / 1000);
return rawEvents
.filter(e => e.start_datetime >= nowUnix)
.sort((a, b) => a.start_datetime - b.start_datetime)
.slice(0, 3)
.map(e => ({
id: e.id,
title: e.title,
placeName: e.place_name || '',
startDatetime: new Date(e.start_datetime * 1000).toISOString(),
tags: Array.isArray(e.tags) ? e.tags : [],
}));
} catch {
return [];
}
},
async getStats(modules: { influence: boolean; map: boolean }) {
const [emailCount, campaignCount, signupCount] = await Promise.allSettled([
modules.influence
? prisma.campaignEmail.count()
: Promise.resolve(0),
modules.influence
? prisma.campaign.count({ where: { status: 'ACTIVE' } })
: Promise.resolve(0),
modules.map
? prisma.shiftSignup.count()
: Promise.resolve(0),
]);
return {
totalEmailsSent: emailCount.status === 'fulfilled' ? emailCount.value : 0,
activeCampaigns: campaignCount.status === 'fulfilled' ? campaignCount.value : 0,
totalShiftSignups: signupCount.status === 'fulfilled' ? signupCount.value : 0,
};
},
};

View File

@@ -3,6 +3,8 @@ import { prisma } from '../../../config/database';
import { AppError } from '../../../middleware/error-handler';
import { emailQueueService } from '../../../services/email-queue.service';
import { recordCampaignEmail } from '../../../utils/metrics';
import { groupService } from '../../social/group.service';
import { achievementsService } from '../../social/achievements.service';
import type { SendCampaignEmailInput, TrackMailtoInput, ListCampaignEmailsInput } from './campaign-emails.schemas';
export const campaignEmailsService = {
@@ -87,6 +89,16 @@ export const campaignEmailsService = {
recordCampaignEmail(campaign.id);
// Social group sync (fire-and-forget)
groupService.syncCampaignTeam(campaign.id).catch(() => {});
// Achievement check for registered users (fire-and-forget)
prisma.user.findUnique({ where: { email: data.userEmail }, select: { id: true } })
.then((user) => {
if (user) achievementsService.checkAndUnlock(user.id, ['campaigns']).catch(() => {});
})
.catch(() => {});
return {
id: campaignEmail.id,
status: campaignEmail.status,
@@ -135,6 +147,9 @@ export const campaignEmailsService = {
},
});
// Social group sync (fire-and-forget)
groupService.syncCampaignTeam(campaign.id).catch(() => {});
return {
id: campaignEmail.id,
status: campaignEmail.status,

View File

@@ -1,5 +1,7 @@
import { Router, Request, Response, NextFunction } from 'express';
import { campaignsService } from './campaigns.service';
import { prisma } from '../../../config/database';
import { redis } from '../../../config/redis';
const router = Router();
@@ -30,4 +32,98 @@ router.get(
}
);
// GET /api/campaigns/:slug/related — related campaigns + upcoming shifts
router.get(
'/:slug/related',
async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const cacheKey = `campaign:related:${slug}`;
// Check cache
try {
const cached = await redis.get(cacheKey);
if (cached) { res.json(JSON.parse(cached)); return; }
} catch { /* cache miss */ }
// Find current campaign
const campaign = await prisma.campaign.findFirst({
where: { slug, status: 'ACTIVE' },
select: { id: true, targetGovernmentLevels: true },
});
if (!campaign) {
res.json({ campaigns: [], shifts: [] });
return;
}
// Related campaigns: same gov levels, exclude current, limit 3
const relatedCampaigns = await prisma.campaign.findMany({
where: {
status: 'ACTIVE',
id: { not: campaign.id },
...(campaign.targetGovernmentLevels.length > 0 && {
targetGovernmentLevels: { hasSome: campaign.targetGovernmentLevels },
}),
},
select: {
id: true,
slug: true,
title: true,
description: true,
_count: { select: { emails: true } },
},
orderBy: { createdAt: 'desc' },
take: 3,
});
// Related shifts: upcoming, limit 3
const today = new Date();
today.setHours(0, 0, 0, 0);
const relatedShifts = await prisma.shift.findMany({
where: {
date: { gte: today },
status: 'OPEN',
},
select: {
id: true,
title: true,
date: true,
startTime: true,
location: true,
maxVolunteers: true,
_count: { select: { signups: true } },
},
orderBy: { date: 'asc' },
take: 3,
});
const result = {
campaigns: relatedCampaigns.map(c => ({
id: c.id,
slug: c.slug,
title: c.title,
description: c.description?.slice(0, 150) ?? null,
emailCount: c._count.emails,
})),
shifts: relatedShifts.map(s => ({
id: s.id,
title: s.title,
startTime: `${s.date.toISOString().split('T')[0]}T${s.startTime}:00`,
location: s.location,
currentVolunteers: s._count.signups,
maxVolunteers: s.maxVolunteers,
})),
};
try { await redis.setex(cacheKey, 300, JSON.stringify(result)); } catch { /* non-critical */ }
res.json(result);
} catch (err) {
next(err);
}
}
);
export { router as campaignPublicRouter };

View File

@@ -0,0 +1,332 @@
import { Router, Request, Response, NextFunction } from 'express';
import crypto from 'crypto';
import { z } from 'zod';
import { authenticate } from '../../middleware/auth.middleware';
import { requireNonTemp } from '../../middleware/rbac.middleware';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
import { prisma } from '../../config/database';
import { siteSettingsService } from '../settings/settings.service';
import { isServiceOnline } from '../../utils/health-check';
import { generateSlug, generateModeratorToken } from './jitsi.utils';
const router = Router();
/** Check if meet is enabled (DB setting wins, env var is fallback for first boot) */
async function isMeetEnabled(): Promise<boolean> {
try {
const settings = await siteSettingsService.get();
return settings.enableMeet;
} catch {
return env.ENABLE_MEET === 'true';
}
}
const tokenSchema = z.object({
room: z.string().min(1).max(200),
});
const createMeetingSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
startTime: z.string().datetime().optional(),
endTime: z.string().datetime().optional(),
});
// GET /api/jitsi/status — health check (any authenticated user)
router.get(
'/status',
authenticate,
async (_req: Request, res: Response, next: NextFunction) => {
try {
const enabled = await isMeetEnabled();
const online = enabled ? await isServiceOnline(env.JITSI_URL) : false;
res.json({ online, enabled });
} catch (err) {
logger.error('Jitsi status check failed:', err);
next(err);
}
},
);
// GET /api/jitsi/config — return Jitsi URLs + enabled status
router.get(
'/config',
authenticate,
async (_req: Request, res: Response, _next: NextFunction) => {
const enabled = await isMeetEnabled();
res.json({
enabled,
embedPort: env.JITSI_EMBED_PORT,
subdomain: 'meet',
domain: env.DOMAIN,
});
},
);
// POST /api/jitsi/token — generate JWT for a standalone meeting room
router.post(
'/token',
authenticate,
requireNonTemp,
async (req: Request, res: Response, next: NextFunction) => {
try {
const enabled = await isMeetEnabled();
if (!enabled) {
res.status(400).json({ error: 'Video meetings are not enabled' });
return;
}
if (!env.JITSI_APP_SECRET) {
res.status(500).json({ error: 'Jitsi JWT secret is not configured' });
return;
}
const parsed = tokenSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid room name (1-200 characters required)' });
return;
}
const { room } = parsed.data;
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: { id: true, email: true, name: true },
});
if (!user) {
res.status(401).json({ error: 'User not found' });
return;
}
const token = generateModeratorToken(user, room);
res.json({ token, room });
} catch (err) {
logger.error('Jitsi token generation failed:', err);
next(err);
}
},
);
// ============================================================================
// MEETING CRUD
// ============================================================================
// GET /api/jitsi/meetings — list user's meetings (authenticated)
router.get(
'/meetings',
authenticate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user!.id;
const userRole = req.user!.role;
const isAdmin = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'].includes(userRole);
// Admins see all meetings, regular users see only their own
const where = isAdmin ? {} : { createdByUserId: userId };
const meetings = await prisma.meeting.findMany({
where,
orderBy: { createdAt: 'desc' },
});
res.json({ meetings });
} catch (err) {
logger.error('List meetings failed:', err);
next(err);
}
},
);
// POST /api/jitsi/meetings — create a meeting (authenticated, non-TEMP)
router.post(
'/meetings',
authenticate,
requireNonTemp,
async (req: Request, res: Response, next: NextFunction) => {
try {
const enabled = await isMeetEnabled();
if (!enabled) {
res.status(400).json({ error: 'Video meetings are not enabled' });
return;
}
const parsed = createMeetingSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid meeting data', details: parsed.error.flatten().fieldErrors });
return;
}
const { title, description, startTime, endTime } = parsed.data;
const meeting = await prisma.meeting.create({
data: {
slug: generateSlug(title),
title,
description: description || null,
jitsiRoom: crypto.randomUUID(),
createdByUserId: req.user!.id,
startTime: startTime ? new Date(startTime) : null,
endTime: endTime ? new Date(endTime) : null,
},
});
res.status(201).json(meeting);
} catch (err) {
logger.error('Create meeting failed:', err);
next(err);
}
},
);
// GET /api/jitsi/meetings/:slug — get meeting details (public, no auth required)
router.get(
'/meetings/:slug',
async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const meeting = await prisma.meeting.findUnique({
where: { slug },
});
if (!meeting) {
res.status(404).json({ error: 'Meeting not found' });
return;
}
res.json(meeting);
} catch (err) {
logger.error('Get meeting failed:', err);
next(err);
}
},
);
// DELETE /api/jitsi/meetings/:id — delete a meeting (owner or SUPER_ADMIN)
router.delete(
'/meetings/:id',
authenticate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const meetingId = req.params.id as string;
const meeting = await prisma.meeting.findUnique({
where: { id: meetingId },
});
if (!meeting) {
res.status(404).json({ error: 'Meeting not found' });
return;
}
// Only the creator or a SUPER_ADMIN can delete
if (meeting.createdByUserId !== req.user!.id && req.user!.role !== 'SUPER_ADMIN') {
res.status(403).json({ error: 'Not authorized to delete this meeting' });
return;
}
await prisma.meeting.delete({ where: { id: meetingId } });
res.json({ success: true });
} catch (err) {
logger.error('Delete meeting failed:', err);
next(err);
}
},
);
// POST /api/jitsi/meetings/:slug/token — generate moderator JWT for a meeting (authenticated, non-TEMP)
router.post(
'/meetings/:slug/token',
authenticate,
requireNonTemp,
async (req: Request, res: Response, next: NextFunction) => {
try {
const enabled = await isMeetEnabled();
if (!enabled) {
res.status(400).json({ error: 'Video meetings are not enabled' });
return;
}
if (!env.JITSI_APP_SECRET) {
res.status(500).json({ error: 'Jitsi JWT secret is not configured' });
return;
}
const slug = req.params.slug as string;
const meeting = await prisma.meeting.findUnique({
where: { slug },
});
if (!meeting) {
res.status(404).json({ error: 'Meeting not found' });
return;
}
if (!meeting.isActive) {
res.status(400).json({ error: 'Meeting is no longer active' });
return;
}
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: { id: true, email: true, name: true },
});
if (!user) {
res.status(401).json({ error: 'User not found' });
return;
}
const token = generateModeratorToken(user, meeting.jitsiRoom);
res.json({ token, jitsiRoom: meeting.jitsiRoom, domain: `meet.${env.DOMAIN}` });
} catch (err) {
logger.error('Meeting token generation failed:', err);
next(err);
}
},
);
// GET /api/jitsi/meetings/:slug/join — public info for guest join page (no auth)
router.get(
'/meetings/:slug/join',
async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const meeting = await prisma.meeting.findUnique({
where: { slug },
select: {
title: true,
description: true,
jitsiRoom: true,
isActive: true,
startTime: true,
endTime: true,
},
});
if (!meeting) {
res.status(404).json({ error: 'Meeting not found' });
return;
}
if (!meeting.isActive) {
res.status(410).json({ error: 'This meeting has ended' });
return;
}
res.json({
title: meeting.title,
description: meeting.description,
jitsiRoom: meeting.jitsiRoom,
domain: `meet.${env.DOMAIN}`,
startTime: meeting.startTime,
endTime: meeting.endTime,
});
} catch (err) {
logger.error('Meeting join info failed:', err);
next(err);
}
},
);
export const jitsiRouter = router;

View File

@@ -0,0 +1,30 @@
import jwt from 'jsonwebtoken';
import { env } from '../../config/env';
export { generateSlug } from '../../utils/slug';
/** Generate a moderator JWT for a given room + user */
export function generateModeratorToken(
user: { id: string; email: string; name: string | null },
room: string,
): string {
const payload = {
context: {
user: {
name: user.name || user.email,
email: user.email,
id: user.id,
moderator: true,
},
},
aud: env.JITSI_APP_ID,
iss: env.JITSI_APP_ID,
sub: 'meet.jitsi',
room,
};
return jwt.sign(payload, env.JITSI_APP_SECRET, {
algorithm: 'HS256',
expiresIn: '2h',
});
}

View File

@@ -12,6 +12,7 @@ import { getAdminEmailsByRole, isNotificationEnabled } from '../../../services/n
import { env } from '../../../config/env';
import { rocketchatWebhookService } from '../../../services/rocketchat-webhook.service';
import { listmonkEventSyncService } from '../../../services/listmonk-event-sync.service';
import { achievementsService } from '../../social/achievements.service';
import type {
RecordVisitInput,
BulkRecordVisitInput,
@@ -631,14 +632,30 @@ export const canvassService = {
updateData.notes = address.notes ? `${prefix}\n${address.notes}` : prefix;
}
await prisma.address.update({
const updatedAddress = await prisma.address.update({
where: { id: data.addressId },
data: updateData,
include: { location: { select: { address: true } } },
});
// Sync support level change to Listmonk (fire-and-forget)
if (updatedAddress.email) {
const name = [updatedAddress.firstName, updatedAddress.lastName].filter(Boolean).join(' ');
listmonkEventSyncService.onAddressUpdated({
email: updatedAddress.email,
name,
supportLevel: updatedAddress.supportLevel,
sign: updatedAddress.sign,
address: updatedAddress.location.address,
}).catch(() => {});
}
}
recordCanvassVisit(data.outcome);
// Achievement check (fire-and-forget)
achievementsService.checkAndUnlock(userId, ['canvass']).catch(() => {});
// Notification: sign request alert for admins
if (data.signRequested) {
try {

View File

@@ -0,0 +1,14 @@
import { Router } from 'express';
import { getMapEvents } from './events.service';
export const eventsPublicRouter = Router();
// GET /public — public map events with resolved lat/lng
eventsPublicRouter.get('/public', async (_req, res, next) => {
try {
const events = await getMapEvents();
res.json(events);
} catch (err) {
next(err);
}
});

View File

@@ -0,0 +1,176 @@
import { env } from '../../../config/env';
import { redis } from '../../../config/redis';
import { prisma } from '../../../config/database';
import { logger } from '../../../utils/logger';
import { parseGeoJsonPolygon, calculateCentroid } from '../../../utils/spatial';
import { geocodingService } from '../geocoding/geocoding.service';
import { siteSettingsService } from '../../settings/settings.service';
// --- Types ---
export interface MapEvent {
id: number;
title: string;
description: string;
placeName: string;
placeAddress: string;
latitude: number;
longitude: number;
startDatetime: string;
endDatetime: string | null;
tags: string[];
shiftId: string | null;
}
// --- Constants ---
const MAP_EVENTS_CACHE_KEY = 'MAP_EVENTS_CACHE';
const CACHE_TTL_SECONDS = 5 * 60; // 5 minutes
const MAX_EVENTS = 50;
const MAX_DAYS_AHEAD = 30;
// --- Service ---
export async function getMapEvents(): Promise<MapEvent[]> {
// Check feature flag
const settings = await siteSettingsService.getPublic();
if (!settings.enableEvents) return [];
// Check Redis cache
try {
const cached = await redis.get(MAP_EVENTS_CACHE_KEY);
if (cached) return JSON.parse(cached) as MapEvent[];
} catch {
// Cache miss or error, proceed with fresh fetch
}
// Fetch events from Gancio public API (no auth needed)
let rawEvents: Array<{
id: number;
title: string;
description: string;
place_name: string;
place_address: string;
start_datetime: number;
end_datetime?: number;
tags: string[];
}>;
try {
const url = `${env.GANCIO_URL}/api/events`;
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!res.ok) {
logger.debug(`Gancio events API returned ${res.status}`);
return [];
}
rawEvents = await res.json() as typeof rawEvents;
} catch (err) {
logger.debug('Failed to fetch events from Gancio for map layer:', err);
return [];
}
// Filter to upcoming events (now → 30 days ahead), limit to 50
const nowUnix = Math.floor(Date.now() / 1000);
const maxUnix = nowUnix + MAX_DAYS_AHEAD * 86400;
const upcoming = rawEvents
.filter(e => e.start_datetime >= nowUnix && e.start_datetime <= maxUnix)
.sort((a, b) => a.start_datetime - b.start_datetime)
.slice(0, MAX_EVENTS);
if (upcoming.length === 0) {
await cacheResult([]);
return [];
}
// Batch-load matching Shifts by gancioEventId (single query)
const gancioIds = upcoming.map(e => e.id);
const shifts = await prisma.shift.findMany({
where: { gancioEventId: { in: gancioIds } },
select: {
id: true,
gancioEventId: true,
cut: { select: { geojson: true } },
},
});
// Build lookup: gancioEventId → { shiftId, geojson }
const shiftLookup = new Map<number, { shiftId: string; geojson: string | null }>();
for (const shift of shifts) {
if (shift.gancioEventId !== null) {
shiftLookup.set(shift.gancioEventId, {
shiftId: shift.id,
geojson: shift.cut?.geojson ?? null,
});
}
}
// Resolve locations for each event
const results: MapEvent[] = [];
for (const event of upcoming) {
const shiftData = shiftLookup.get(event.id) ?? null;
const coords = await resolveEventLocation(event.place_address, shiftData?.geojson ?? null);
if (!coords) continue;
results.push({
id: event.id,
title: event.title,
description: (event.description || '').slice(0, 300),
placeName: event.place_name || '',
placeAddress: event.place_address || '',
latitude: coords.lat,
longitude: coords.lng,
startDatetime: new Date(event.start_datetime * 1000).toISOString(),
endDatetime: event.end_datetime ? new Date(event.end_datetime * 1000).toISOString() : null,
tags: Array.isArray(event.tags) ? event.tags : [],
shiftId: shiftData?.shiftId ?? null,
});
}
await cacheResult(results);
return results;
}
/**
* Resolve lat/lng for a Gancio event.
* Priority: 1) Shift's Cut centroid, 2) Geocode place_address, 3) null (skip)
*/
async function resolveEventLocation(
placeAddress: string,
geojson: string | null,
): Promise<{ lat: number; lng: number } | null> {
// Try centroid from Shift's Cut polygon
if (geojson) {
try {
const rings = parseGeoJsonPolygon(geojson);
if (rings.length > 0 && rings[0]!.length > 0) {
return calculateCentroid(rings[0]!);
}
} catch {
// Bad GeoJSON, fall through to geocoding
}
}
// Fallback: geocode the place_address
if (placeAddress && placeAddress !== 'TBD') {
try {
const result = await geocodingService.geocode(placeAddress);
if (result) {
return { lat: result.latitude, lng: result.longitude };
}
} catch {
// Geocoding failed, skip this event
}
}
return null;
}
async function cacheResult(events: MapEvent[]): Promise<void> {
try {
await redis.setex(MAP_EVENTS_CACHE_KEY, CACHE_TTL_SECONDS, JSON.stringify(events));
} catch {
// Non-critical, log silently
}
}

View File

@@ -8,6 +8,7 @@ import { geocodingService } from '../geocoding/geocoding.service';
import { logger } from '../../../utils/logger';
import { recordLocationQuery } from '../../../utils/metrics';
import { isPointInPolygon, parseGeoJsonPolygon } from '../../../utils/spatial';
import { mapSettingsService } from '../settings/settings.service';
import type { CreateLocationInput, UpdateLocationInput, ListLocationsInput, BulkImportInput } from './locations.schemas';
// Statistics Canada Lambert Conformal Conic projection (EPSG:3347) → WGS84 (EPSG:4326)
@@ -313,7 +314,7 @@ export const locationsService = {
return location;
},
async create(data: CreateLocationInput, userId: string) {
async create(data: CreateLocationInput, userId: string | null) {
// Split data into Location (building) and Address (unit) fields
const locationData: Prisma.LocationUncheckedCreateInput = {
address: data.address,
@@ -770,6 +771,23 @@ export const locationsService = {
take: 5000, // Safety limit
});
// Server-side enforcement: strip sensitive fields based on map visibility settings
const mapSettings = await mapSettingsService.get();
if (!mapSettings.publicShowSupportLevels || !mapSettings.publicShowSignInfo) {
for (const loc of locations) {
for (const addr of loc.addresses) {
if (!mapSettings.publicShowSupportLevels) {
(addr as any).supportLevel = null;
}
if (!mapSettings.publicShowSignInfo) {
(addr as any).sign = false;
(addr as any).signSize = null;
}
}
}
}
const durationSeconds = (Date.now() - startTime) / 1000;
recordLocationQuery('public', !!bounds, locations.length, durationSeconds);

View File

@@ -14,6 +14,12 @@ export const updateMapSettingsSchema = z.object({
qrCode3Url: z.string().url().nullable().optional().or(z.literal('')),
qrCode3Label: z.string().nullable().optional(),
publicMapEnabled: z.boolean().optional(),
publicShowLocations: z.boolean().optional(),
publicShowSupportLevels: z.boolean().optional(),
publicShowCuts: z.boolean().optional(),
publicShowEvents: z.boolean().optional(),
publicShowAddresses: z.boolean().optional(),
publicShowSignInfo: z.boolean().optional(),
});
export type UpdateMapSettingsInput = z.infer<typeof updateMapSettingsSchema>;

View File

@@ -12,6 +12,8 @@ import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { shiftSignupRateLimit } from '../../../middleware/rate-limit';
import { prisma } from '../../../config/database';
import { redis } from '../../../config/redis';
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
@@ -156,6 +158,34 @@ adminRouter.delete(
}
);
// POST /api/map/shifts/:id/meeting — create and link a video briefing meeting
adminRouter.post(
'/:id/meeting',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const meeting = await shiftsService.createMeetingForShift(id, req.user!.id);
res.status(201).json(meeting);
} catch (err) {
next(err);
}
}
);
// DELETE /api/map/shifts/:id/meeting — remove video briefing from shift
adminRouter.delete(
'/:id/meeting',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
await shiftsService.removeMeetingFromShift(id);
res.status(204).send();
} catch (err) {
next(err);
}
}
);
// POST /api/map/shifts/:id/email-details — email all volunteers
adminRouter.post(
'/:id/email-details',
@@ -261,4 +291,47 @@ publicRouter.post(
}
);
// GET /api/map/shifts/public/related — related active campaigns
publicRouter.get(
'/public/related',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const cacheKey = 'shifts:related:campaigns';
try {
const cached = await redis.get(cacheKey);
if (cached) { res.json(JSON.parse(cached)); return; }
} catch { /* cache miss */ }
const campaigns = await prisma.campaign.findMany({
where: { status: 'ACTIVE' },
select: {
id: true,
slug: true,
title: true,
description: true,
_count: { select: { emails: true } },
},
orderBy: { createdAt: 'desc' },
take: 3,
});
const result = {
campaigns: campaigns.map(c => ({
id: c.id,
slug: c.slug,
title: c.title,
description: c.description?.slice(0, 150) ?? null,
emailCount: c._count.emails,
})),
};
try { await redis.setex(cacheKey, 300, JSON.stringify(result)); } catch { /* non-critical */ }
res.json(result);
} catch (err) {
next(err);
}
}
);
export { adminRouter as shiftsAdminRouter, publicRouter as shiftsPublicRouter, volunteerRouter as shiftsVolunteerRouter };

View File

@@ -11,6 +11,12 @@ import { recordShiftSignup } from '../../../utils/metrics';
import { rocketchatWebhookService } from '../../../services/rocketchat-webhook.service';
import { listmonkEventSyncService } from '../../../services/listmonk-event-sync.service';
import { gancioClient } from '../../../services/gancio.client';
import { unifiedCalendarService } from '../../events/unified-calendar.service';
import { groupService } from '../../social/group.service';
import { achievementsService } from '../../social/achievements.service';
import { generateSlug } from '../../../utils/slug';
import { siteSettingsService } from '../../settings/settings.service';
import crypto from 'crypto';
import type {
CreateShiftInput,
UpdateShiftInput,
@@ -29,6 +35,14 @@ function generateReadablePassword(): string {
return `${adj}${noun}${num}`;
}
const meetingSelect = {
id: true,
slug: true,
title: true,
isActive: true,
jitsiRoom: true,
} as const;
export const shiftsService = {
async findAll(filters: ListShiftsInput) {
const { page, limit, search, status, upcoming, sortBy, sortOrder } = filters;
@@ -64,6 +78,7 @@ export const shiftsService = {
orderBy,
include: {
cut: { select: { id: true, name: true } },
meeting: { select: meetingSelect },
_count: {
select: {
signups: { where: { status: SignupStatus.CONFIRMED } },
@@ -90,6 +105,7 @@ export const shiftsService = {
where: { id },
include: {
cut: { select: { id: true, name: true } },
meeting: { select: meetingSelect },
signups: {
where: { status: SignupStatus.CONFIRMED },
include: { user: { select: { id: true, email: true, name: true, phone: true } } },
@@ -147,6 +163,9 @@ export const shiftsService = {
});
}
// Bust unified calendar cache
unifiedCalendarService.bustCache().catch(() => {});
return shift;
},
@@ -190,6 +209,9 @@ export const shiftsService = {
});
}
// Bust unified calendar cache
unifiedCalendarService.bustCache().catch(() => {});
return shift;
},
@@ -206,7 +228,55 @@ export const shiftsService = {
});
}
// Delete associated meeting if exists
if (existing.meetingId) {
await prisma.meeting.delete({ where: { id: existing.meetingId } }).catch(() => {});
}
await prisma.shift.delete({ where: { id } });
// Bust unified calendar cache
unifiedCalendarService.bustCache().catch(() => {});
},
async createMeetingForShift(shiftId: string, userId: string) {
const shift = await prisma.shift.findUnique({ where: { id: shiftId } });
if (!shift) throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
if (shift.meetingId) throw new AppError(400, 'Shift already has a meeting', 'MEETING_EXISTS');
const settings = await siteSettingsService.get();
if (!settings.enableMeet) throw new AppError(400, 'Video meetings are not enabled', 'MEET_DISABLED');
const meeting = await prisma.meeting.create({
data: {
slug: generateSlug(shift.title),
title: `${shift.title} — Video Briefing`,
jitsiRoom: crypto.randomUUID(),
createdByUserId: userId,
},
});
await prisma.shift.update({
where: { id: shiftId },
data: { meetingId: meeting.id },
});
return meeting;
},
async removeMeetingFromShift(shiftId: string) {
const shift = await prisma.shift.findUnique({ where: { id: shiftId } });
if (!shift) throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
if (!shift.meetingId) throw new AppError(400, 'Shift has no meeting', 'NO_MEETING');
const meetingId = shift.meetingId;
await prisma.shift.update({
where: { id: shiftId },
data: { meetingId: null },
});
// Delete the meeting record
await prisma.meeting.delete({ where: { id: meetingId } }).catch(() => {});
},
async getStats() {
@@ -301,6 +371,12 @@ export const shiftsService = {
shiftDate: new Date(shift.date).toISOString().split('T')[0],
}).catch(() => {});
// Social group sync (fire-and-forget)
groupService.syncShiftTeam(shiftId).catch(() => {});
// Achievement check (fire-and-forget)
if (user?.id) achievementsService.checkAndUnlock(user.id, ['shifts']).catch(() => {});
return signup;
},
@@ -327,6 +403,9 @@ export const shiftsService = {
},
}),
]);
// Social group sync (fire-and-forget)
groupService.syncShiftTeam(signup.shiftId).catch(() => {});
},
async publicSignup(shiftId: string, data: PublicSignupInput) {
@@ -516,6 +595,32 @@ export const shiftsService = {
logger.error('Failed to schedule shift reminder:', err);
}
// Notification: schedule post-shift thank-you (2h after end)
try {
if (await isNotificationEnabled('notifyVolunteerShiftThankYou')) {
const shiftEndDatetime = new Date(shift.date);
const [endH, endM] = shift.endTime.split(':').map(Number);
shiftEndDatetime.setHours(endH || 0, endM || 0, 0, 0);
const shiftDate = new Date(shift.date);
const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
const signupUrl = `${env.CORS_ORIGINS.split(',')[0].trim()}/shifts`;
await notificationQueueService.scheduleShiftThankYou({
type: 'volunteer-shift-thank-you',
volunteerEmail: data.email,
volunteerName: data.name,
shiftTitle: shift.title,
shiftDate: dateStr,
shiftTime: `${shift.startTime}${shift.endTime}`,
shiftLocation: shift.location || 'TBD',
signupUrl,
}, shiftEndDatetime);
}
} catch (err) {
logger.error('Failed to schedule shift thank-you:', err);
}
recordShiftSignup();
// Listmonk event sync
@@ -526,6 +631,12 @@ export const shiftsService = {
shiftDate: new Date(shift.date).toISOString().split('T')[0],
}).catch(() => {});
// Social group sync (fire-and-forget)
groupService.syncShiftTeam(shiftId).catch(() => {});
// Achievement check (fire-and-forget)
if (user?.id) achievementsService.checkAndUnlock(user.id, ['shifts']).catch(() => {});
return { signup, isNewUser };
},
@@ -582,6 +693,14 @@ export const shiftsService = {
shiftDatetime.setHours(startH || 0, startM || 0, 0, 0);
await notificationQueueService.cancelShiftReminder(userEmail, shiftDatetime);
}
// Cancel the pending shift thank-you
if (shift) {
const shiftEndDatetime = new Date(shift.date);
const [endH, endM] = shift.endTime.split(':').map(Number);
shiftEndDatetime.setHours(endH || 0, endM || 0, 0, 0);
await notificationQueueService.cancelShiftThankYou(userEmail, shiftEndDatetime);
}
} catch (err) {
logger.error('Failed to enqueue cancellation notification:', err);
}
@@ -619,6 +738,9 @@ export const shiftsService = {
} catch (err) {
logger.error('Failed to enqueue admin shift cancellation notification:', err);
}
// Social group sync (fire-and-forget)
groupService.syncShiftTeam(shiftId).catch(() => {});
},
async getUpcomingForVolunteer(userId: string) {
@@ -642,6 +764,7 @@ export const shiftsService = {
maxVolunteers: true,
currentVolunteers: true,
status: true,
meeting: { select: { id: true, slug: true, isActive: true } },
},
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
});
@@ -803,6 +926,32 @@ export const shiftsService = {
logger.error('Failed to schedule shift reminder:', err);
}
// Notification: schedule post-shift thank-you (2h after end)
try {
if (await isNotificationEnabled('notifyVolunteerShiftThankYou')) {
const shiftEndDatetime = new Date(shift.date);
const [endH, endM] = shift.endTime.split(':').map(Number);
shiftEndDatetime.setHours(endH || 0, endM || 0, 0, 0);
const shiftDate = new Date(shift.date);
const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
const signupUrl = `${env.CORS_ORIGINS.split(',')[0].trim()}/shifts`;
await notificationQueueService.scheduleShiftThankYou({
type: 'volunteer-shift-thank-you',
volunteerEmail: user.email,
volunteerName: user.name || user.email,
shiftTitle: shift.title,
shiftDate: dateStr,
shiftTime: `${shift.startTime}${shift.endTime}`,
shiftLocation: shift.location || 'TBD',
signupUrl,
}, shiftEndDatetime);
}
} catch (err) {
logger.error('Failed to schedule shift thank-you:', err);
}
// Listmonk event sync
listmonkEventSyncService.onShiftSignup({
email: user.email,
@@ -811,6 +960,12 @@ export const shiftsService = {
shiftDate: new Date(shift.date).toISOString().split('T')[0],
}).catch(() => {});
// Social group sync (fire-and-forget)
groupService.syncShiftTeam(shiftId).catch(() => {});
// Achievement check (fire-and-forget)
achievementsService.checkAndUnlock(userId, ['shifts']).catch(() => {});
return signup;
},
@@ -865,6 +1020,14 @@ export const shiftsService = {
shiftDatetime.setHours(startH || 0, startM || 0, 0, 0);
await notificationQueueService.cancelShiftReminder(user.email, shiftDatetime);
}
// Cancel the pending shift thank-you
if (shift) {
const shiftEndDatetime = new Date(shift.date);
const [endH, endM] = shift.endTime.split(':').map(Number);
shiftEndDatetime.setHours(endH || 0, endM || 0, 0, 0);
await notificationQueueService.cancelShiftThankYou(user.email, shiftEndDatetime);
}
} catch (err) {
logger.error('Failed to enqueue cancellation notification:', err);
}
@@ -902,6 +1065,9 @@ export const shiftsService = {
} catch (err) {
logger.error('Failed to enqueue admin shift cancellation notification:', err);
}
// Social group sync (fire-and-forget)
groupService.syncShiftTeam(shiftId).catch(() => {});
},
async getMySignups(userId: string) {
@@ -930,6 +1096,7 @@ export const shiftsService = {
maxVolunteers: true,
currentVolunteers: true,
status: true,
meeting: { select: { id: true, slug: true, isActive: true } },
},
},
},
@@ -957,6 +1124,7 @@ export const shiftsService = {
maxVolunteers: true,
currentVolunteers: true,
status: true,
meeting: { select: { id: true, slug: true, isActive: true } },
},
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
});

View File

@@ -1,9 +1,50 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import jwt from 'jsonwebtoken';
import { UserRole, UserStatus } from '@prisma/client';
import { prisma } from '../../../config/database';
import { env } from '../../../config/env';
import { requireAdminRole } from '../middleware/auth';
import { logger } from '../../../utils/logger';
import { hasAnyRole, ADMIN_ROLES } from '../../../utils/roles';
import { unlink } from 'fs/promises';
/**
* Check if the request is from an authenticated admin user.
* Supports JWT from Authorization header or ?token= query parameter
* (needed for <img src> which can't send headers).
*/
async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
try {
let token: string | undefined;
const authHeader = request.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
token = authHeader.substring(7);
} else {
const query = request.query as Record<string, string | undefined>;
token = query.token;
}
if (!token) return false;
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as {
id: string;
role: UserRole;
roles?: UserRole[];
};
if (!hasAnyRole(payload, ADMIN_ROLES)) return false;
const user = await prisma.user.findUnique({
where: { id: payload.id },
select: { status: true },
});
return user?.status === UserStatus.ACTIVE;
} catch {
return false;
}
}
/**
* Admin photo CRUD routes (prefix: /api/photos)
*/
@@ -138,14 +179,22 @@ export async function photosRoutes(fastify: FastifyInstance) {
}
);
// GET /api/photos/:id/thumbnail - Serve thumbnail image (admin)
// GET /api/photos/:id/thumbnail - Serve thumbnail image
// Public endpoint with admin bypass for unpublished photos (matches video thumbnail pattern)
fastify.get<{ Params: { id: string } }>(
'/:id/thumbnail',
{ preHandler: requireAdminRole },
async (request, reply) => {
const id = parseInt(request.params.id as string);
const photo = await prisma.photo.findUnique({
where: { id },
if (isNaN(id)) {
return reply.code(400).send({ message: 'Invalid photo ID' });
}
// Admin bypass: skip publication filter for authenticated admin users
const admin = await isAdminRequest(request);
const photo = await prisma.photo.findFirst({
where: admin
? { id }
: { id, isPublished: true, isLocked: false },
select: { thumbnailPath: true },
});
@@ -154,6 +203,7 @@ export async function photosRoutes(fastify: FastifyInstance) {
}
if (photo.thumbnailPath.includes('..')) {
logger.warn(`Path traversal attempt detected: ${photo.thumbnailPath}`);
return reply.code(403).send({ message: 'Access denied' });
}

View File

@@ -0,0 +1,77 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redis } from '../../config/redis';
import { env } from '../../config/env';
import { listmonkClient } from '../../services/listmonk.client';
import { logger } from '../../utils/logger';
const router = Router();
const subscribeRateLimit = rateLimit({
windowMs: 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:newsletter-subscribe:',
}),
message: { error: { message: 'Too many subscribe attempts, please try again later', code: 'NEWSLETTER_RATE_LIMIT_EXCEEDED' } },
});
const subscribeSchema = z.object({
email: z.string().email(),
name: z.string().optional(),
});
const PUBLIC_LIST_NAME = 'Public Updates';
// POST /api/newsletter/subscribe
router.post('/subscribe', subscribeRateLimit, async (req: Request, res: Response, next: NextFunction) => {
try {
// Check if Listmonk is enabled
if (env.LISTMONK_SYNC_ENABLED !== 'true') {
res.status(503).json({ error: { message: 'Newsletter subscriptions are not currently available' } });
return;
}
const body = subscribeSchema.parse(req.body);
// Find or create the "Public Updates" list
let lists = await listmonkClient.getLists();
let publicList = lists.find(l => l.name === PUBLIC_LIST_NAME);
if (!publicList) {
publicList = await listmonkClient.createList(PUBLIC_LIST_NAME, 'public', ['public', 'auto']);
}
// Create or update subscriber (double opt-in via Listmonk's optin flow)
try {
await listmonkClient.createSubscriber(
body.email,
body.name || '',
[publicList.id],
{ source: 'public_signup' },
);
} catch (err: any) {
// If subscriber already exists, that's fine
if (err?.message?.includes('already exists') || err?.statusCode === 409) {
// subscriber already exists, success
} else {
throw err;
}
}
res.json({ success: true, message: 'Check your email to confirm your subscription' });
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: { message: 'Please provide a valid email address' } });
return;
}
logger.error('Newsletter subscribe error:', err);
next(err);
}
});
export { router as newsletterPublicRouter };

View File

@@ -0,0 +1,170 @@
import { Router, Request, Response, NextFunction } from 'express';
import { prisma } from '../../config/database';
import { redis } from '../../config/redis';
import { siteSettingsService } from '../settings/settings.service';
import { escapeHtml } from '../../utils/escapeHtml';
const router = Router();
const CACHE_TTL = 600; // 10 minutes
/** Generate minimal HTML with OG tags + JS redirect for non-bots */
function ogHtml(opts: {
title: string;
description: string;
image?: string | null;
url: string;
siteName: string;
type?: string;
}): string {
const safeTitle = escapeHtml(opts.title);
const safeDesc = escapeHtml(opts.description);
const safeSite = escapeHtml(opts.siteName);
const safeUrl = escapeHtml(opts.url);
const safeImage = opts.image ? escapeHtml(opts.image) : '';
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${safeTitle}</title>
<meta property="og:title" content="${safeTitle}">
<meta property="og:description" content="${safeDesc}">
<meta property="og:site_name" content="${safeSite}">
<meta property="og:url" content="${safeUrl}">
<meta property="og:type" content="${opts.type || 'website'}">
${safeImage ? `<meta property="og:image" content="${safeImage}">` : ''}
<meta name="twitter:card" content="${safeImage ? 'summary_large_image' : 'summary'}">
<meta name="twitter:title" content="${safeTitle}">
<meta name="twitter:description" content="${safeDesc}">
${safeImage ? `<meta name="twitter:image" content="${safeImage}">` : ''}
<meta http-equiv="refresh" content="0;url=${safeUrl}">
</head>
<body>
<p>Redirecting to <a href="${safeUrl}">${safeTitle}</a>...</p>
</body>
</html>`;
}
// GET /api/og/campaign/:slug
router.get('/campaign/:slug', async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const cacheKey = `og:campaign:${slug}`;
try {
const cached = await redis.get(cacheKey);
if (cached) { res.type('html').send(cached); return; }
} catch { /* cache miss */ }
const campaign = await prisma.campaign.findFirst({
where: { slug, status: 'ACTIVE' },
select: { title: true, description: true, coverPhoto: true },
});
if (!campaign) { res.status(404).send('Not found'); return; }
const settings = await siteSettingsService.getPublic();
const appDomain = req.get('host') || 'app.cmlite.org';
const protocol = req.protocol;
const url = `${protocol}://${appDomain}/campaign/${slug}`;
const html = ogHtml({
title: campaign.title,
description: campaign.description?.slice(0, 200) || `${campaign.title} — Take action now`,
image: campaign.coverPhoto || null,
url,
siteName: settings.organizationName || 'Changemaker Lite',
});
try { await redis.setex(cacheKey, CACHE_TTL, html); } catch { /* non-critical */ }
res.type('html').send(html);
} catch (err) {
next(err);
}
});
// GET /api/og/page/:slug
router.get('/page/:slug', async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const cacheKey = `og:page:${slug}`;
try {
const cached = await redis.get(cacheKey);
if (cached) { res.type('html').send(cached); return; }
} catch { /* cache miss */ }
const page = await prisma.landingPage.findFirst({
where: { slug, published: true },
select: { title: true, description: true, seoTitle: true, seoDescription: true, seoImage: true },
});
if (!page) { res.status(404).send('Not found'); return; }
const settings = await siteSettingsService.getPublic();
const appDomain = req.get('host') || 'app.cmlite.org';
const protocol = req.protocol;
const url = `${protocol}://${appDomain}/p/${slug}`;
const html = ogHtml({
title: page.seoTitle || page.title,
description: page.seoDescription || page.description?.slice(0, 200) || page.title,
image: page.seoImage || null,
url,
siteName: settings.organizationName || 'Changemaker Lite',
});
try { await redis.setex(cacheKey, CACHE_TTL, html); } catch { /* non-critical */ }
res.type('html').send(html);
} catch (err) {
next(err);
}
});
// GET /api/og/gallery/:id
router.get('/gallery/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const cacheKey = `og:gallery:${id}`;
try {
const cached = await redis.get(cacheKey);
if (cached) { res.type('html').send(cached); return; }
} catch { /* cache miss */ }
const videoId = parseInt(id, 10);
if (isNaN(videoId)) { res.status(404).send('Not found'); return; }
const video = await prisma.video.findFirst({
where: { id: videoId, isPublished: true },
select: { title: true, thumbnailPath: true },
});
if (!video) { res.status(404).send('Not found'); return; }
const settings = await siteSettingsService.getPublic();
const appDomain = req.get('host') || 'app.cmlite.org';
const protocol = req.protocol;
const url = `${protocol}://${appDomain}/gallery/watch/${id}`;
const html = ogHtml({
title: video.title || 'Video',
description: video.title || 'Watch this video',
image: video.thumbnailPath ? `${protocol}://${appDomain}/media/public/${videoId}/thumbnail` : null,
url,
siteName: settings.organizationName || 'Changemaker Lite',
type: 'video.other',
});
try { await redis.setex(cacheKey, CACHE_TTL, html); } catch { /* non-critical */ }
res.type('html').send(html);
} catch (err) {
next(err);
}
});
export { router as ogRouter };

View File

@@ -1,8 +1,32 @@
import { Router, Request, Response, NextFunction } from 'express';
import { prisma } from '../../config/database';
import { pagesService } from './pages.service';
const router = Router();
// GET /api/pages/listed — get published + listed pages for public index (no auth)
router.get(
'/listed',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const pages = await prisma.landingPage.findMany({
where: { published: true, listed: true },
select: {
slug: true,
title: true,
description: true,
seoImage: true,
updatedAt: true,
},
orderBy: { updatedAt: 'desc' },
});
res.json(pages);
} catch (err) {
next(err);
}
}
);
// GET /api/pages/:slug/view — get published page by slug (public)
router.get(
'/:slug/view',

View File

@@ -0,0 +1,92 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { validate } from '../../middleware/validate';
import { donationPagesService } from './donation-pages.service';
import {
createDonationPageSchema,
updateDonationPageSchema,
listDonationPagesSchema,
} from './donation-pages.schemas';
const router = Router();
// All routes require SUPER_ADMIN
router.use(authenticate, requireRole(UserRole.SUPER_ADMIN));
// GET /api/payments/admin/donation-pages — list with pagination, search, status
router.get(
'/',
validate(listDonationPagesSchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await donationPagesService.findAll(
req.query as Record<string, unknown> as Parameters<typeof donationPagesService.findAll>[0],
);
res.json(result);
} catch (err) {
next(err);
}
},
);
// GET /api/payments/admin/donation-pages/:id — get by ID
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const page = await donationPagesService.findById(req.params.id as string);
res.json(page);
} catch (err) {
next(err);
}
});
// POST /api/payments/admin/donation-pages — create
router.post(
'/',
validate(createDonationPageSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const page = await donationPagesService.create(req.body, req.user!.id);
res.status(201).json(page);
} catch (err) {
next(err);
}
},
);
// PUT /api/payments/admin/donation-pages/:id — update
router.put(
'/:id',
validate(updateDonationPageSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const page = await donationPagesService.update(req.params.id as string, req.body);
res.json(page);
} catch (err) {
next(err);
}
},
);
// DELETE /api/payments/admin/donation-pages/:id — delete
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await donationPagesService.delete(req.params.id as string);
res.json({ success: true });
} catch (err) {
next(err);
}
});
// GET /api/payments/admin/donation-pages/:id/stats — page-specific stats
router.get('/:id/stats', async (req: Request, res: Response, next: NextFunction) => {
try {
const stats = await donationPagesService.getPageStats(req.params.id as string);
res.json(stats);
} catch (err) {
next(err);
}
});
export { router as donationPagesAdminRouter };

View File

@@ -0,0 +1,67 @@
import { Router, Request, Response, NextFunction } from 'express';
import { validate } from '../../middleware/validate';
import { donationPagesService } from './donation-pages.service';
import { donationsService } from './donations.service';
import { donationPageCheckoutSchema } from './donation-pages.schemas';
const router = Router();
// GET /api/donation-pages — list active pages (with stats)
router.get('/', async (_req: Request, res: Response, next: NextFunction) => {
try {
const pages = await donationPagesService.findActivePages();
res.json(pages);
} catch (err) {
next(err);
}
});
// GET /api/donation-pages/:slug — get active page by slug
router.get('/:slug', async (req: Request, res: Response, next: NextFunction) => {
try {
const page = await donationPagesService.findBySlugPublic(req.params.slug as string);
res.json(page);
} catch (err) {
next(err);
}
});
// POST /api/donation-pages/:slug/donate — create Stripe checkout for this page
router.post(
'/:slug/donate',
validate(donationPageCheckoutSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const page = await donationPagesService.findBySlugPublic(req.params.slug as string);
const { amountCents, email, name, message, isAnonymous } = req.body;
// Validate against page-specific minimum
if (amountCents < page.minimumAmount) {
res.status(400).json({
error: {
message: `Minimum donation is $${(page.minimumAmount / 100).toFixed(2)}`,
code: 'MINIMUM_NOT_MET',
},
});
return;
}
const result = await donationsService.createDonationCheckout(
amountCents,
email,
name,
message,
isAnonymous,
page.id,
page.slug,
page.title,
);
res.json(result);
} catch (err) {
next(err);
}
},
);
export { router as donationPagesPublicRouter };

View File

@@ -0,0 +1,39 @@
import { z } from 'zod';
export const createDonationPageSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(5000).nullable().optional(),
status: z.enum(['DRAFT', 'ACTIVE', 'PAUSED', 'ARCHIVED']).default('DRAFT'),
suggestedAmounts: z.array(z.number().int().min(100)).optional(),
minimumAmount: z.number().int().min(100).default(500),
thankYouMessage: z.string().max(2000).optional(),
coverPhoto: z.string().max(500).nullable().optional(),
coverVideoId: z.number().int().positive().nullable().optional(),
highlightPage: z.boolean().optional(),
showDonorCount: z.boolean().optional(),
showTotalRaised: z.boolean().optional(),
goalAmount: z.number().int().min(100).nullable().optional(),
});
export type CreateDonationPageInput = z.infer<typeof createDonationPageSchema>;
export const updateDonationPageSchema = createDonationPageSchema.partial();
export type UpdateDonationPageInput = z.infer<typeof updateDonationPageSchema>;
export const listDonationPagesSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().optional(),
status: z.enum(['DRAFT', 'ACTIVE', 'PAUSED', 'ARCHIVED']).optional(),
});
export type ListDonationPagesInput = z.infer<typeof listDonationPagesSchema>;
export const donationPageCheckoutSchema = z.object({
amountCents: z.number().int().min(100),
email: z.string().email(),
name: z.string().max(200).optional(),
message: z.string().max(2000).optional(),
isAnonymous: z.boolean().optional(),
});

View File

@@ -0,0 +1,283 @@
import { Prisma } from '@prisma/client';
import { prisma } from '../../config/database';
import { AppError } from '../../middleware/error-handler';
import type { CreateDonationPageInput, UpdateDonationPageInput, ListDonationPagesInput } from './donation-pages.schemas';
function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
}
async function resolveSlugCollision(slug: string, excludeId?: string): Promise<string> {
let candidate = slug;
let suffix = 2;
while (true) {
const existing = await prisma.donationPage.findUnique({
where: { slug: candidate },
select: { id: true },
});
if (!existing || (excludeId && existing.id === excludeId)) {
return candidate;
}
candidate = `${slug}-${suffix}`;
suffix++;
}
}
export const donationPagesService = {
/** Admin: list donation pages with pagination, search, status filter */
async findAll(filters: ListDonationPagesInput) {
const { page, limit, search, status } = filters;
const skip = (page - 1) * limit;
const where: Prisma.DonationPageWhereInput = {};
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
];
}
if (status) where.status = status;
const [pages, total] = await Promise.all([
prisma.donationPage.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
_count: {
select: {
orders: { where: { status: 'COMPLETED' } },
},
},
},
}),
prisma.donationPage.count({ where }),
]);
// Compute totalRaised for each page
const pagesWithStats = await Promise.all(
pages.map(async (p) => {
const agg = await prisma.order.aggregate({
where: { donationPageId: p.id, status: 'COMPLETED' },
_sum: { amountCAD: true },
});
return {
...p,
totalRaised: agg._sum.amountCAD || 0,
};
}),
);
return {
pages: pagesWithStats,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
/** Admin: get by ID with stats */
async findById(id: string) {
const page = await prisma.donationPage.findUnique({
where: { id },
include: {
_count: {
select: {
orders: { where: { status: 'COMPLETED' } },
},
},
},
});
if (!page) {
throw new AppError(404, 'Donation page not found', 'DONATION_PAGE_NOT_FOUND');
}
const agg = await prisma.order.aggregate({
where: { donationPageId: id, status: 'COMPLETED' },
_sum: { amountCAD: true },
});
return { ...page, totalRaised: agg._sum.amountCAD || 0 };
},
/** Create donation page with auto-generated slug */
async create(data: CreateDonationPageInput, userId: string) {
const baseSlug = generateSlug(data.title);
const slug = await resolveSlugCollision(baseSlug);
// Enforce highlight exclusivity
if (data.highlightPage) {
await prisma.donationPage.updateMany({
where: { highlightPage: true },
data: { highlightPage: false },
});
}
const page = await prisma.donationPage.create({
data: {
slug,
title: data.title,
description: data.description ?? null,
status: data.status || 'DRAFT',
suggestedAmounts: data.suggestedAmounts ?? [1000, 2500, 5000, 10000],
minimumAmount: data.minimumAmount ?? 500,
thankYouMessage: data.thankYouMessage ?? 'Thank you for your support!',
coverPhoto: data.coverPhoto ?? null,
coverVideoId: data.coverVideoId ?? null,
highlightPage: data.highlightPage ?? false,
showDonorCount: data.showDonorCount ?? true,
showTotalRaised: data.showTotalRaised ?? false,
goalAmount: data.goalAmount ?? null,
createdByUserId: userId,
},
});
return page;
},
/** Update donation page, regenerate slug if title changes */
async update(id: string, data: UpdateDonationPageInput) {
const existing = await prisma.donationPage.findUnique({ where: { id } });
if (!existing) {
throw new AppError(404, 'Donation page not found', 'DONATION_PAGE_NOT_FOUND');
}
const updateData: Prisma.DonationPageUncheckedUpdateInput = { ...data };
// Regenerate slug if title changes
if (data.title && data.title !== existing.title) {
const baseSlug = generateSlug(data.title);
updateData.slug = await resolveSlugCollision(baseSlug, id);
}
// Enforce highlight exclusivity
if (data.highlightPage) {
await prisma.donationPage.updateMany({
where: { highlightPage: true, id: { not: id } },
data: { highlightPage: false },
});
}
return prisma.donationPage.update({
where: { id },
data: updateData,
});
},
/** Delete donation page */
async delete(id: string) {
const existing = await prisma.donationPage.findUnique({
where: { id },
include: { _count: { select: { orders: { where: { status: 'COMPLETED' } } } } },
});
if (!existing) {
throw new AppError(404, 'Donation page not found', 'DONATION_PAGE_NOT_FOUND');
}
// Nullify order references before deleting
await prisma.order.updateMany({
where: { donationPageId: id },
data: { donationPageId: null },
});
await prisma.donationPage.delete({ where: { id } });
},
/** Public: list active donation pages with stats */
async findActivePages() {
const pages = await prisma.donationPage.findMany({
where: { status: 'ACTIVE' },
orderBy: [
{ highlightPage: 'desc' },
{ createdAt: 'desc' },
],
});
return Promise.all(
pages.map(async (p) => {
const [agg, donorCount] = await Promise.all([
prisma.order.aggregate({
where: { donationPageId: p.id, status: 'COMPLETED' },
_sum: { amountCAD: true },
}),
prisma.order.count({
where: { donationPageId: p.id, status: 'COMPLETED' },
}),
]);
return {
...p,
totalRaised: agg._sum.amountCAD || 0,
donorCount,
};
}),
);
},
/** Public: get active page by slug, 404 if not ACTIVE */
async findBySlugPublic(slug: string) {
const page = await prisma.donationPage.findUnique({
where: { slug },
});
if (!page) {
throw new AppError(404, 'Donation page not found', 'DONATION_PAGE_NOT_FOUND');
}
if (page.status !== 'ACTIVE') {
throw new AppError(404, 'Donation page not found', 'DONATION_PAGE_NOT_FOUND');
}
const [agg, donorCount] = await Promise.all([
prisma.order.aggregate({
where: { donationPageId: page.id, status: 'COMPLETED' },
_sum: { amountCAD: true },
}),
prisma.order.count({
where: { donationPageId: page.id, status: 'COMPLETED' },
}),
]);
return {
...page,
totalRaised: agg._sum.amountCAD || 0,
donorCount,
};
},
/** Admin: get aggregate stats for a page */
async getPageStats(pageId: string) {
const [totalDonations, totalAmount, uniqueDonors, avgDonation] = await Promise.all([
prisma.order.count({
where: { donationPageId: pageId, status: 'COMPLETED' },
}),
prisma.order.aggregate({
where: { donationPageId: pageId, status: 'COMPLETED' },
_sum: { amountCAD: true },
}),
prisma.order.groupBy({
by: ['buyerEmail'],
where: { donationPageId: pageId, status: 'COMPLETED' },
}),
prisma.order.aggregate({
where: { donationPageId: pageId, status: 'COMPLETED' },
_avg: { amountCAD: true },
}),
]);
return {
totalDonations,
totalAmount: totalAmount._sum.amountCAD || 0,
uniqueDonors: uniqueDonors.length,
averageDonation: avgDonation._avg.amountCAD || 0,
};
},
};

View File

@@ -13,23 +13,33 @@ export const donationsService = {
name?: string,
message?: string,
isAnonymous?: boolean,
donationPageId?: string,
donationPageSlug?: string,
donationPageTitle?: string,
) {
const settings = await paymentSettingsService.get();
if (!settings.enableDonations) throw new Error('Donations are currently disabled');
if (amountCents < settings.donationMinimum) {
// Use page-specific minimum if provided via page route, otherwise global
if (!donationPageId && amountCents < settings.donationMinimum) {
throw new Error(`Minimum donation is $${(settings.donationMinimum / 100).toFixed(2)}`);
}
const stripe = await getStripe();
const productName = donationPageTitle ? `Donation — ${donationPageTitle}` : 'Donation';
const cancelUrl = donationPageSlug
? `${env.ADMIN_URL}/donate/${donationPageSlug}`
: `${env.ADMIN_URL}/donate`;
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{
price_data: {
currency: settings.defaultCurrency || 'cad',
product_data: {
name: 'Donation',
description: settings.donationPageTitle || 'Support Our Work',
name: productName,
description: donationPageTitle || settings.donationPageTitle || 'Support Our Work',
},
unit_amount: amountCents,
},
@@ -37,13 +47,14 @@ export const donationsService = {
}],
customer_email: email,
success_url: `${env.ADMIN_URL}/payments/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.ADMIN_URL}/donate`,
cancel_url: cancelUrl,
metadata: {
type: 'donation',
email,
name: name || '',
message: message || '',
isAnonymous: isAnonymous ? 'true' : 'false',
donationPageId: donationPageId || '',
},
});
@@ -58,6 +69,7 @@ export const donationsService = {
buyerName: name || null,
donorMessage: message || null,
isAnonymous: isAnonymous || false,
donationPageId: donationPageId || null,
},
});
@@ -65,8 +77,8 @@ export const donationsService = {
},
/** List donations (admin) */
async listDonations(filters: { page: number; limit: number; search?: string }) {
const { page, limit, search } = filters;
async listDonations(filters: { page: number; limit: number; search?: string; donationPageId?: string }) {
const { page, limit, search, donationPageId } = filters;
const where: Record<string, unknown> = { type: 'donation' };
if (search) {
(where as Record<string, unknown>).OR = [
@@ -74,6 +86,11 @@ export const donationsService = {
{ buyerName: { contains: search, mode: 'insensitive' } },
];
}
if (donationPageId === 'general') {
(where as Record<string, unknown>).donationPageId = null;
} else if (donationPageId) {
(where as Record<string, unknown>).donationPageId = donationPageId;
}
const [orders, total] = await Promise.all([
prisma.order.findMany({
@@ -81,6 +98,7 @@ export const donationsService = {
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
include: { donationPage: { select: { id: true, title: true, slug: true } } },
}),
prisma.order.count({ where: where as import('@prisma/client').Prisma.OrderWhereInput }),
]);
@@ -124,7 +142,7 @@ export const donationsService = {
},
/** Export donations to CSV */
async exportToCsv(filters: { search?: string; status?: string }) {
async exportToCsv(filters: { search?: string; status?: string; donationPageId?: string }) {
const where: Record<string, unknown> = { type: 'donation' };
if (filters.status) {
(where as Record<string, unknown>).status = filters.status;
@@ -135,10 +153,16 @@ export const donationsService = {
{ buyerName: { contains: filters.search, mode: 'insensitive' } },
];
}
if (filters.donationPageId === 'general') {
(where as Record<string, unknown>).donationPageId = null;
} else if (filters.donationPageId) {
(where as Record<string, unknown>).donationPageId = filters.donationPageId;
}
const orders = await prisma.order.findMany({
where: where as import('@prisma/client').Prisma.OrderWhereInput,
orderBy: { createdAt: 'desc' },
include: { donationPage: { select: { title: true } } },
});
return stringify(orders.map((o) => ({
@@ -147,6 +171,7 @@ export const donationsService = {
'Donor Email': o.isAnonymous ? '' : (o.buyerEmail || ''),
'Amount (CAD)': (o.amountCAD / 100).toFixed(2),
'Status': o.status,
'Donation Page': o.donationPage?.title || 'General',
'Message': o.donorMessage || '',
'Anonymous': o.isAnonymous ? 'Yes' : 'No',
'Stripe Payment Intent': o.stripePaymentIntentId || '',

View File

@@ -5,12 +5,14 @@ import { requireRole } from '../../middleware/rbac.middleware';
import { validate } from '../../middleware/validate';
import { paymentSettingsService } from './payment-settings.service';
import { subscriptionsService } from './subscriptions.service';
import { plansService } from './plans.service';
import { productsService } from './products.service';
import { donationsService } from './donations.service';
import {
updatePaymentSettingsSchema,
createPlanSchema,
updatePlanSchema,
listPlansSchema,
createProductSchema,
updateProductSchema,
subscriptionFiltersSchema,
@@ -90,11 +92,25 @@ router.get('/dashboard', async (_req: Request, res: Response, next: NextFunction
// =================== Plans ===================
// GET /api/payments/admin/plans
router.get('/plans', async (_req: Request, res: Response, next: NextFunction) => {
router.get(
'/plans',
validate(listPlansSchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await plansService.findAll(req.query as Record<string, unknown> as Parameters<typeof plansService.findAll>[0]);
res.json(result);
} catch (err) {
next(err);
}
}
);
// GET /api/payments/admin/plans/:id
router.get('/plans/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { prisma } = await import('../../config/database');
const plans = await prisma.subscriptionPlan.findMany({ orderBy: { displayOrder: 'asc' } });
res.json(plans);
const id = parseInt(req.params.id as string, 10);
const plan = await plansService.findById(id);
res.json(plan);
} catch (err) {
next(err);
}
@@ -106,7 +122,7 @@ router.post(
validate(createPlanSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const plan = await subscriptionsService.createPlan(req.body);
const plan = await plansService.create(req.body);
res.status(201).json(plan);
} catch (err) {
next(err);
@@ -121,7 +137,7 @@ router.put(
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = parseInt(req.params.id as string, 10);
const plan = await subscriptionsService.updatePlan(id, req.body);
const plan = await plansService.update(id, req.body);
res.json(plan);
} catch (err) {
next(err);
@@ -133,7 +149,7 @@ router.put(
router.delete('/plans/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = parseInt(req.params.id as string, 10);
await subscriptionsService.deletePlan(id);
await plansService.delete(id);
res.json({ success: true });
} catch (err) {
next(err);
@@ -294,7 +310,8 @@ router.get('/donations/export', async (req: Request, res: Response, next: NextFu
try {
const search = req.query.search as string | undefined;
const status = req.query.status as string | undefined;
const csv = await donationsService.exportToCsv({ search, status });
const donationPageId = req.query.donationPageId as string | undefined;
const csv = await donationsService.exportToCsv({ search, status, donationPageId });
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=donations-export.csv');
res.send(csv);
@@ -309,7 +326,8 @@ router.get('/donations', async (req: Request, res: Response, next: NextFunction)
const page = parseInt(req.query.page as string, 10) || 1;
const limit = parseInt(req.query.limit as string, 10) || 20;
const search = req.query.search as string | undefined;
const result = await donationsService.listDonations({ page, limit, search });
const donationPageId = req.query.donationPageId as string | undefined;
const result = await donationsService.listDonations({ page, limit, search, donationPageId });
res.json(result);
} catch (err) {
next(err);

View File

@@ -2,6 +2,7 @@ import { Router, Request, Response, NextFunction } from 'express';
import { getPublishableKey } from '../../services/stripe.client';
import { paymentSettingsService } from './payment-settings.service';
import { subscriptionsService } from './subscriptions.service';
import { plansService } from './plans.service';
import { productsService } from './products.service';
import { donationsService } from './donations.service';
import { authenticate } from '../../middleware/auth.middleware';
@@ -37,13 +38,24 @@ router.get('/config', async (_req: Request, res: Response, next: NextFunction) =
// GET /api/payments/plans — list active subscription plans
router.get('/plans', async (_req: Request, res: Response, next: NextFunction) => {
try {
const plans = await subscriptionsService.listActivePlans();
const plans = await plansService.listActivePlans();
res.json(plans);
} catch (err) {
next(err);
}
});
// GET /api/payments/plans/:slug — public plan detail by slug
router.get('/plans/:slug', async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const plan = await plansService.findBySlugPublic(slug);
res.json(plan);
} catch (err) {
next(err);
}
});
// GET /api/payments/products — list active products
router.get('/products', async (req: Request, res: Response, next: NextFunction) => {
try {
@@ -55,6 +67,21 @@ router.get('/products', async (req: Request, res: Response, next: NextFunction)
}
});
// GET /api/payments/products/:slug — single active product by slug (detail page)
router.get('/products/:slug', async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const product = await productsService.getBySlug(slug);
if (!product) {
res.status(404).json({ error: { message: 'Product not found', code: 'NOT_FOUND' } });
return;
}
res.json(product);
} catch (err) {
next(err);
}
});
// POST /api/payments/subscribe — create subscription checkout (requires login)
router.post(
'/subscribe',

View File

@@ -29,10 +29,23 @@ export const createPlanSchema = z.object({
tier: z.number().int().min(0).optional(),
displayOrder: z.number().int().min(0).optional(),
isActive: z.boolean().optional(),
coverPhoto: z.string().max(1000).nullable().optional(),
coverVideoId: z.number().int().positive().nullable().optional(),
richDescription: z.string().max(50000).nullable().optional(),
ctaText: z.string().max(200).nullable().optional(),
ctaSubtext: z.string().max(500).nullable().optional(),
highlightPlan: z.boolean().optional(),
});
export const updatePlanSchema = createPlanSchema.partial();
export const listPlansSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().optional(),
isActive: z.enum(['true', 'false']).optional(),
});
// --- Subscribe ---
export const createSubscriptionCheckoutSchema = z.object({
@@ -50,6 +63,9 @@ export const createProductSchema = z.object({
type: z.enum(['DIGITAL', 'EVENT', 'DONATION']),
isActive: z.boolean().optional(),
imageUrl: z.string().url().nullable().optional().or(z.literal('')),
photoId: z.number().int().positive().nullable().optional(),
videoId: z.number().int().positive().nullable().optional(),
galleryPhotoIds: z.array(z.number().int().positive()).nullable().optional(),
downloadUrl: z.string().max(1000).nullable().optional(),
metadata: z.record(z.unknown()).nullable().optional(),
maxPurchases: z.number().int().min(1).nullable().optional(),

View File

@@ -0,0 +1,186 @@
import { Prisma } from '@prisma/client';
import { prisma } from '../../config/database';
import { AppError } from '../../middleware/error-handler';
function generateSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
}
async function resolveSlugCollision(slug: string, excludeId?: number): Promise<string> {
let candidate = slug;
let suffix = 2;
while (true) {
const existing = await prisma.subscriptionPlan.findUnique({
where: { slug: candidate },
select: { id: true },
});
if (!existing || (excludeId && existing.id === excludeId)) {
return candidate;
}
candidate = `${slug}-${suffix}`;
suffix++;
}
}
export const plansService = {
/** Admin: list plans with pagination, search, active filter */
async findAll(filters: { page: number; limit: number; search?: string; isActive?: string }) {
const { page, limit, search, isActive } = filters;
const skip = (page - 1) * limit;
const where: Prisma.SubscriptionPlanWhereInput = {};
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
];
}
if (isActive === 'true') where.isActive = true;
if (isActive === 'false') where.isActive = false;
const [plans, total] = await Promise.all([
prisma.subscriptionPlan.findMany({
where,
skip,
take: limit,
orderBy: { displayOrder: 'asc' },
include: {
_count: {
select: {
subscriptions: { where: { status: 'active' } },
},
},
},
}),
prisma.subscriptionPlan.count({ where }),
]);
return {
plans,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
/** Admin: get single plan by ID */
async findById(id: number) {
const plan = await prisma.subscriptionPlan.findUnique({
where: { id },
include: {
_count: {
select: {
subscriptions: { where: { status: 'active' } },
},
},
},
});
if (!plan) {
throw new AppError(404, 'Plan not found', 'PLAN_NOT_FOUND');
}
return plan;
},
/** Create plan with auto-generated slug */
async create(data: Prisma.SubscriptionPlanUncheckedCreateInput) {
const baseSlug = generateSlug(data.name);
const slug = await resolveSlugCollision(baseSlug);
// Enforce highlight exclusivity
if (data.highlightPlan) {
await prisma.subscriptionPlan.updateMany({
where: { highlightPlan: true },
data: { highlightPlan: false },
});
}
return prisma.subscriptionPlan.create({
data: {
...data,
slug,
},
});
},
/** Update plan, regenerate slug if name changes */
async update(id: number, data: Prisma.SubscriptionPlanUncheckedUpdateInput) {
const existing = await prisma.subscriptionPlan.findUnique({ where: { id } });
if (!existing) {
throw new AppError(404, 'Plan not found', 'PLAN_NOT_FOUND');
}
const updateData: Prisma.SubscriptionPlanUncheckedUpdateInput = { ...data };
// Regenerate slug if name changes
if (typeof data.name === 'string' && data.name !== existing.name) {
const baseSlug = generateSlug(data.name);
updateData.slug = await resolveSlugCollision(baseSlug, id);
}
// Enforce highlight exclusivity
if (data.highlightPlan === true) {
await prisma.subscriptionPlan.updateMany({
where: { highlightPlan: true, id: { not: id } },
data: { highlightPlan: false },
});
}
return prisma.subscriptionPlan.update({
where: { id },
data: updateData,
});
},
/** Delete plan (blocked if active subscriptions exist) */
async delete(id: number) {
const activeSubs = await prisma.userSubscription.count({
where: { planId: id, status: 'active' },
});
if (activeSubs > 0) {
throw new Error(`Cannot delete plan with ${activeSubs} active subscriptions`);
}
return prisma.subscriptionPlan.delete({ where: { id } });
},
/** Public: list active plans for pricing page */
async listActivePlans() {
return prisma.subscriptionPlan.findMany({
where: { isActive: true },
orderBy: [
{ highlightPlan: 'desc' },
{ displayOrder: 'asc' },
],
});
},
/** Public: find active plan by slug for detail page */
async findBySlugPublic(slug: string) {
const plan = await prisma.subscriptionPlan.findUnique({
where: { slug },
});
if (!plan) {
throw new AppError(404, 'Plan not found', 'PLAN_NOT_FOUND');
}
if (!plan.isActive) {
throw new AppError(404, 'Plan not found', 'PLAN_NOT_FOUND');
}
// Get active subscriber count
const activeSubscribers = await prisma.userSubscription.count({
where: { planId: plan.id, status: 'active' },
});
return { ...plan, activeSubscribers };
},
};

View File

@@ -4,15 +4,50 @@ import { env } from '../../config/env';
import type { Prisma, OrderStatus, ProductType } from '@prisma/client';
import { logger } from '../../utils/logger';
/** Resolve media IDs to public-facing URLs on a product */
function resolveMediaUrls<T extends { imageUrl: string | null; photoId: number | null; videoId: number | null; galleryPhotoIds: unknown }>(product: T) {
const photoIds = Array.isArray(product.galleryPhotoIds) ? product.galleryPhotoIds as number[] : null;
return {
...product,
// Prefer gallery photo over external URL
resolvedImageUrl: product.photoId
? `/media/public/photos/${product.photoId}/image?size=medium`
: product.imageUrl ?? null,
thumbnailUrl: product.photoId
? `/media/public/photos/${product.photoId}/thumbnail`
: null,
// Promotional video
videoThumbnailUrl: product.videoId
? `/media/videos/${product.videoId}/thumbnail`
: null,
videoStreamUrl: product.videoId
? `/media/videos/${product.videoId}/stream`
: null,
// Gallery photo array
galleryImages: photoIds
? photoIds.map((id: number) => ({
photoId: id,
thumbnailUrl: `/media/public/photos/${id}/thumbnail`,
imageUrl: `/media/public/photos/${id}/image?size=medium`,
}))
: null,
};
}
/** Map product type to gallery ad defaults */
function productAdDefaults(product: { title: string; description: string | null; type: ProductType; slug: string; imageUrl: string | null; priceCAD: number }) {
function productAdDefaults(product: { title: string; description: string | null; type: ProductType; slug: string; imageUrl: string | null; photoId: number | null; priceCAD: number }) {
const priceStr = `$${(product.priceCAD / 100).toFixed(2)}`;
// Prefer gallery photo URL for ad image
const imagePath = product.photoId
? `/media/public/photos/${product.photoId}/image?size=medium`
: product.imageUrl ?? null;
const base = {
title: product.title,
subtitle: product.description
? product.description.slice(0, 120) + (product.description.length > 120 ? '...' : '')
: null,
imagePath: product.imageUrl ?? null,
imagePath,
variant: 'standard',
visibility: 'everyone',
isActive: false, // admin enables manually
@@ -57,10 +92,11 @@ export const productsService = {
async listActive(type?: string) {
const where: Prisma.ProductWhereInput = { isActive: true };
if (type) where.type = type as Prisma.EnumProductTypeFilter['equals'];
return prisma.product.findMany({
const products = await prisma.product.findMany({
where,
orderBy: { createdAt: 'desc' },
});
return products.map(resolveMediaUrls);
},
/** List all products (admin) */
@@ -86,13 +122,21 @@ export const productsService = {
]);
return {
products,
products: products.map(resolveMediaUrls),
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async getById(id: string) {
return prisma.product.findUnique({ where: { id } });
const product = await prisma.product.findUnique({ where: { id } });
return product ? resolveMediaUrls(product) : null;
},
/** Get a single active product by slug (public detail page) */
async getBySlug(slug: string) {
const product = await prisma.product.findUnique({ where: { slug } });
if (!product || !product.isActive) return null;
return resolveMediaUrls(product);
},
async create(data: Prisma.ProductUncheckedCreateInput) {
@@ -113,7 +157,7 @@ export const productsService = {
logger.warn(`Failed to auto-create gallery ad for product ${product.id}: ${err}`);
}
return product;
return resolveMediaUrls(product);
},
async update(id: string, data: Prisma.ProductUncheckedUpdateInput) {
@@ -127,7 +171,13 @@ export const productsService = {
const desc = typeof data.description === 'string' ? data.description : null;
updates.subtitle = desc ? desc.slice(0, 120) + (desc.length > 120 ? '...' : '') : null;
}
if (data.imageUrl !== undefined) updates.imagePath = data.imageUrl as string | null;
// Prefer photoId URL for ad image, fall back to imageUrl
if (data.photoId !== undefined || data.imageUrl !== undefined) {
const pid = data.photoId !== undefined ? data.photoId as number | null : product.photoId;
updates.imagePath = pid
? `/media/public/photos/${pid}/image?size=medium`
: (data.imageUrl !== undefined ? data.imageUrl as string | null : product.imageUrl);
}
if (data.isActive === false) updates.isActive = false;
if (data.slug !== undefined) {
// Update link URL for non-donation products
@@ -150,7 +200,7 @@ export const productsService = {
logger.warn(`Failed to sync gallery ad for product ${id}: ${err}`);
}
return product;
return resolveMediaUrls(product);
},
async delete(id: string) {

View File

@@ -226,13 +226,20 @@ export const webhookService = {
? session.payment_intent
: (session.payment_intent as { id: string } | null)?.id || null;
// Link to donation page if metadata contains donationPageId (from page-specific checkout)
const donationPageId = session.metadata?.donationPageId || null;
const updateData: Record<string, unknown> = {
status: 'COMPLETED',
stripePaymentIntentId: paymentIntentId,
completedAt: new Date(),
};
if (donationPageId && !order.donationPageId) {
updateData.donationPageId = donationPageId;
}
await prisma.order.update({
where: { id: order.id },
data: {
status: 'COMPLETED',
stripePaymentIntentId: paymentIntentId,
completedAt: new Date(),
},
data: updateData as import('@prisma/client').Prisma.OrderUncheckedUpdateInput,
});
await this.createAuditLog('donation_completed', {

View File

@@ -0,0 +1,753 @@
import { Router, Request, Response, NextFunction } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { validate } from '../../middleware/validate';
import { ADMIN_ROLES } from '../../utils/roles';
import { prisma } from '../../config/database';
import { peopleService } from './people.service';
import { profileService } from './profile.service';
import { tagsService } from './tags.service';
import {
listPeopleSchema,
managePersonSchema,
createContactSchema,
updateContactSchema,
createConnectionSchema,
mergeContactSchema,
graphQuerySchema,
activityListSchema,
createUserFromContactSchema,
addContactAddressSchema,
addContactEmailSchema,
addContactPhoneSchema,
generateProfileLinkSchema,
updateProfileLinkSchema,
} from './people.schemas';
import {
createTagSchema,
updateTagSchema,
deleteTagQuerySchema,
bulkTagSchema,
} from './tags.schemas';
const router = Router();
// All routes require admin auth
router.use(authenticate, requireRole(...ADMIN_ROLES));
// ---------------------------------------------------------------------------
// List & Search
// ---------------------------------------------------------------------------
// GET /api/people — list/search all virtual people
router.get(
'/',
validate(listPeopleSchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await peopleService.listPeople(req.query as any);
res.json(result);
} catch (err) {
next(err);
}
}
);
// GET /api/people/stats — aggregate stats
router.get(
'/stats',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const stats = await peopleService.getStats();
res.json(stats);
} catch (err) {
next(err);
}
}
);
// GET /api/people/duplicates — find potential duplicates
router.get(
'/duplicates',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const duplicates = await peopleService.getDuplicates();
res.json({ duplicates });
} catch (err) {
next(err);
}
}
);
// ---------------------------------------------------------------------------
// Graph
// ---------------------------------------------------------------------------
// GET /api/people/graph — build graph nodes + edges
router.get(
'/graph',
validate(graphQuerySchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await peopleService.getGraphData(req.query as any);
res.json(result);
} catch (err) {
next(err);
}
}
);
// ---------------------------------------------------------------------------
// Household
// ---------------------------------------------------------------------------
// GET /api/people/household/:locationId — all people at a location
router.get(
'/household/:locationId',
async (req: Request, res: Response, next: NextFunction) => {
try {
const locationId = req.params.locationId as string;
const result = await peopleService.getHousehold(locationId);
res.json(result);
} catch (err) {
next(err);
}
}
);
// POST /api/people/household/:locationId/detect — auto-create HOUSEHOLD connections
router.post(
'/household/:locationId/detect',
async (req: Request, res: Response, next: NextFunction) => {
try {
const locationId = req.params.locationId as string;
const result = await peopleService.detectHousehold(locationId);
res.json(result);
} catch (err) {
next(err);
}
}
);
// ---------------------------------------------------------------------------
// CRM Management
// ---------------------------------------------------------------------------
// POST /api/people/manage — promote a virtual person to managed Contact
router.post(
'/manage',
validate(managePersonSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const contact = await peopleService.manageContact(req.body, req.user!.id);
res.status(201).json(contact);
} catch (err) {
next(err);
}
}
);
// POST /api/people/contacts — create a new Contact directly (manual add)
router.post(
'/contacts',
validate(createContactSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const contact = await peopleService.createContact(req.body, req.user!.id);
res.status(201).json(contact);
} catch (err) {
next(err);
}
}
);
// PUT /api/people/contacts/:id — update Contact CRM fields
router.put(
'/contacts/:id',
validate(updateContactSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const contact = await peopleService.updateContact(id, req.body);
res.json(contact);
} catch (err) {
next(err);
}
}
);
// DELETE /api/people/contacts/:id — delete a Contact (virtual person still visible)
router.delete(
'/contacts/:id',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
await peopleService.deleteContact(id);
res.status(204).send();
} catch (err) {
next(err);
}
}
);
// POST /api/people/contacts/:id/merge — merge another person into this Contact
router.post(
'/contacts/:id/merge',
validate(mergeContactSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const result = await peopleService.mergeContacts(id, req.body);
res.json(result);
} catch (err) {
next(err);
}
}
);
// ---------------------------------------------------------------------------
// Profile Link Management
// ---------------------------------------------------------------------------
// POST /api/people/contacts/:id/profile-link — Generate profile token (with optional expiration/password)
router.post(
'/contacts/:id/profile-link',
validate(generateProfileLinkSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const result = await profileService.generateProfileToken(id, req.body);
res.json(result);
} catch (err) {
next(err);
}
}
);
// PUT /api/people/contacts/:id/profile-link — Update settings on existing link (no token regeneration)
router.put(
'/contacts/:id/profile-link',
validate(updateProfileLinkSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const result = await profileService.updateProfileLinkSettings(id, req.body);
res.json(result);
} catch (err) {
if (err instanceof Error && err.message.includes('not found or has no profile link')) {
res.status(404).json({ error: { message: err.message, code: 'NOT_FOUND' } });
return;
}
next(err);
}
}
);
// DELETE /api/people/contacts/:id/profile-link — Revoke profile token
router.delete(
'/contacts/:id/profile-link',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
await profileService.revokeProfileToken(id);
res.status(204).send();
} catch (err) {
next(err);
}
}
);
// POST /api/people/contacts/:id/send-profile-link — Send profile link via email
router.post(
'/contacts/:id/send-profile-link',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const result = await profileService.sendProfileLink(id, req.user!.id);
res.json(result);
} catch (err) {
next(err);
}
}
);
// ---------------------------------------------------------------------------
// Contact Addresses (Map Integration)
// ---------------------------------------------------------------------------
// GET /api/people/contacts/:id/addresses — list linked addresses
router.get(
'/contacts/:id/addresses',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const addresses = await peopleService.getAddresses(id);
res.json({ addresses });
} catch (err) {
next(err);
}
}
);
// POST /api/people/contacts/:id/addresses — add address (with map sync)
router.post(
'/contacts/:id/addresses',
validate(addContactAddressSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const result = await peopleService.addAddress(id, req.body, req.user!.id);
res.status(201).json(result);
} catch (err) {
next(err);
}
}
);
// DELETE /api/people/contacts/:id/addresses/:linkId — unlink address
router.delete(
'/contacts/:id/addresses/:linkId',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const linkId = req.params.linkId as string;
await peopleService.removeAddress(id, linkId);
res.status(204).send();
} catch (err) {
next(err);
}
}
);
// ---------------------------------------------------------------------------
// Contact Emails
// ---------------------------------------------------------------------------
// GET /api/people/contacts/:id/emails — list emails for a contact
router.get(
'/contacts/:id/emails',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const emails = await peopleService.getEmails(id);
res.json({ emails });
} catch (err) {
next(err);
}
}
);
// POST /api/people/contacts/:id/emails — add email to a contact
router.post(
'/contacts/:id/emails',
validate(addContactEmailSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const emails = await peopleService.addEmail(id, req.body);
res.status(201).json({ emails });
} catch (err) {
next(err);
}
}
);
// PUT /api/people/contacts/:id/emails/:emailId/primary — set primary email
router.put(
'/contacts/:id/emails/:emailId/primary',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const emailId = req.params.emailId as string;
await peopleService.setPrimaryEmail(id, emailId);
res.json({ success: true });
} catch (err) {
next(err);
}
}
);
// DELETE /api/people/contacts/:id/emails/:emailId — remove email
router.delete(
'/contacts/:id/emails/:emailId',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const emailId = req.params.emailId as string;
await peopleService.removeEmail(id, emailId);
res.status(204).send();
} catch (err) {
next(err);
}
}
);
// ---------------------------------------------------------------------------
// Contact Phones
// ---------------------------------------------------------------------------
// GET /api/people/contacts/:id/phones — list phones for a contact
router.get(
'/contacts/:id/phones',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const phones = await peopleService.getPhones(id);
res.json({ phones });
} catch (err) {
next(err);
}
}
);
// POST /api/people/contacts/:id/phones — add phone to a contact
router.post(
'/contacts/:id/phones',
validate(addContactPhoneSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const phones = await peopleService.addPhone(id, req.body);
res.status(201).json({ phones });
} catch (err) {
next(err);
}
}
);
// PUT /api/people/contacts/:id/phones/:phoneId/primary — set primary phone
router.put(
'/contacts/:id/phones/:phoneId/primary',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const phoneId = req.params.phoneId as string;
await peopleService.setPrimaryPhone(id, phoneId);
res.json({ success: true });
} catch (err) {
next(err);
}
}
);
// DELETE /api/people/contacts/:id/phones/:phoneId — remove phone
router.delete(
'/contacts/:id/phones/:phoneId',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const phoneId = req.params.phoneId as string;
await peopleService.removePhone(id, phoneId);
res.status(204).send();
} catch (err) {
next(err);
}
}
);
// ---------------------------------------------------------------------------
// Connections
// ---------------------------------------------------------------------------
// GET /api/people/contacts/:id/connections — list connections for a contact
router.get(
'/contacts/:id/connections',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const connections = await peopleService.getConnections(id);
res.json({ connections });
} catch (err) {
next(err);
}
}
);
// POST /api/people/contacts/:id/connections — create a new connection
router.post(
'/contacts/:id/connections',
validate(createConnectionSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const connection = await peopleService.createConnection(id, req.body, req.user!.id);
res.status(201).json(connection);
} catch (err) {
next(err);
}
}
);
// DELETE /api/people/connections/:id — remove a connection
router.delete(
'/connections/:id',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
await peopleService.deleteConnection(id);
res.status(204).send();
} catch (err) {
next(err);
}
}
);
// ---------------------------------------------------------------------------
// Profile Preview (admin)
// ---------------------------------------------------------------------------
// GET /api/people/contacts/:id/profile-preview — admin preview of contact profile
router.get(
'/contacts/:id/profile-preview',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const profile = await profileService.getProfileByContactId(id);
if (!profile) {
res.status(404).json({ error: { message: 'Contact not found', code: 'NOT_FOUND' } });
return;
}
res.json(profile);
} catch (err) {
next(err);
}
}
);
// GET /api/people/contacts/:id/profile-photo — serve cover photo by contact ID
router.get(
'/contacts/:id/profile-photo',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const size = (req.query.size as string) === 'thumb' ? 'thumb' : 'cover';
const result = profileService.serveCoverPhotoByContactId(id, size as 'cover' | 'thumb');
if (!result) {
res.status(404).json({ error: { message: 'Photo not found', code: 'NOT_FOUND' } });
return;
}
res.setHeader('Content-Type', result.contentType);
res.setHeader('Cache-Control', 'private, max-age=300');
result.stream.pipe(res);
} catch (err) {
next(err);
}
}
);
// ---------------------------------------------------------------------------
// Create User from Contact
// ---------------------------------------------------------------------------
// POST /api/people/contacts/:id/create-user — create user account from contact
router.post(
'/contacts/:id/create-user',
validate(createUserFromContactSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const result = await peopleService.createUserFromContact(id, req.body, req.user!.id);
res.status(201).json(result);
} catch (err) {
next(err);
}
}
);
// ---------------------------------------------------------------------------
// CRM Tag Management
// ---------------------------------------------------------------------------
// GET /api/people/tags — list all registered CRM tags
router.get(
'/tags',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const tags = await tagsService.listTags();
res.json({ tags });
} catch (err) {
next(err);
}
}
);
// GET /api/people/tags/unregistered — discover tags not in registry
router.get(
'/tags/unregistered',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const tags = await tagsService.getUnregisteredTags();
res.json({ tags });
} catch (err) {
next(err);
}
}
);
// POST /api/people/tags — create a new CRM tag
router.post(
'/tags',
validate(createTagSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const tag = await tagsService.createTag(req.body, req.user!.id);
res.status(201).json(tag);
} catch (err) {
next(err);
}
}
);
// POST /api/people/tags/register-all — bulk register unregistered tags
router.post(
'/tags/register-all',
async (req: Request, res: Response, next: NextFunction) => {
try {
const syncToListmonk = req.body.syncToListmonk === true;
const result = await tagsService.registerExistingTags({ syncToListmonk }, req.user!.id);
res.json(result);
} catch (err) {
next(err);
}
}
);
// POST /api/people/tags/bulk-add — add tag to multiple contacts
router.post(
'/tags/bulk-add',
validate(bulkTagSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await tagsService.bulkAddTag(req.body);
res.json(result);
} catch (err) {
next(err);
}
}
);
// POST /api/people/tags/bulk-remove — remove tag from multiple contacts
router.post(
'/tags/bulk-remove',
validate(bulkTagSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await tagsService.bulkRemoveTag(req.body);
res.json(result);
} catch (err) {
next(err);
}
}
);
// POST /api/people/tags/sync-all — sync all tags to Listmonk
router.post(
'/tags/sync-all',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const result = await tagsService.syncAllTagsToListmonk();
res.json(result);
} catch (err) {
next(err);
}
}
);
// PUT /api/people/tags/:id — update a CRM tag
router.put(
'/tags/:id',
validate(updateTagSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const tag = await tagsService.updateTag(id, req.body);
res.json(tag);
} catch (err) {
next(err);
}
}
);
// DELETE /api/people/tags/:id — delete a CRM tag
router.delete(
'/tags/:id',
validate(deleteTagQuerySchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
await tagsService.deleteTag(id, {
removeFromContacts: req.query.removeFromContacts === 'true',
deleteListmonkList: req.query.deleteListmonkList === 'true',
});
res.status(204).send();
} catch (err) {
next(err);
}
}
);
// POST /api/people/tags/:id/sync — sync single tag to Listmonk
router.post(
'/tags/:id/sync',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const result = await tagsService.syncTagToListmonk(id);
res.json(result);
} catch (err) {
next(err);
}
}
);
// POST /api/people/tags/:id/recount — recalculate tag contact count
router.post(
'/tags/:id/recount',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
await tagsService.recalculateCounts(id);
const tag = await prisma.crmTag.findUnique({ where: { id } });
res.json(tag);
} catch (err) {
next(err);
}
}
);
// ---------------------------------------------------------------------------
// Person detail & activity
// ---------------------------------------------------------------------------
// GET /api/people/:type/:id — get a single person with engagement
router.get(
'/:type/:id',
async (req: Request, res: Response, next: NextFunction) => {
try {
const type = req.params.type as string;
const id = req.params.id as string;
const result = await peopleService.getPersonDetail(type, id);
res.json(result);
} catch (err) {
next(err);
}
}
);
// GET /api/people/:type/:id/activity — activity timeline for a person
router.get(
'/:type/:id/activity',
validate(activityListSchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const type = req.params.type as string;
const id = req.params.id as string;
const result = await peopleService.getActivity(type, id, req.query as any);
res.json(result);
} catch (err) {
next(err);
}
}
);
export { router as peopleRouter };

View File

@@ -0,0 +1,147 @@
import { z } from 'zod';
// List people query params
export const listPeopleSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
search: z.string().optional(),
source: z.enum(['USER', 'ADDRESS_OCCUPANT', 'CAMPAIGN_SENDER', 'SHIFT_SIGNUP', 'SMS_CONTACT', 'DONATION', 'MANUAL']).optional(),
supportLevel: z.enum(['LEVEL_1', 'LEVEL_2', 'LEVEL_3', 'LEVEL_4']).optional(),
tag: z.string().optional(),
managedOnly: z.coerce.boolean().optional(),
});
// Manage (promote) a virtual person to Contact
export const managePersonSchema = z.object({
sourceType: z.enum(['user', 'addr', 'contact', 'donor', 'cemail', 'signup', 'sms']),
sourceId: z.string(),
displayName: z.string().optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
email: z.string().email().optional().or(z.literal('')),
phone: z.string().optional(),
});
// Update Contact
export const updateContactSchema = z.object({
displayName: z.string().min(1).optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
email: z.string().email().optional().nullable().or(z.literal('')),
phone: z.string().optional().nullable(),
tags: z.array(z.string()).optional(),
notes: z.string().optional().nullable(),
supportLevel: z.enum(['LEVEL_1', 'LEVEL_2', 'LEVEL_3', 'LEVEL_4']).optional().nullable(),
signRequested: z.boolean().optional(),
emailOptOut: z.boolean().optional(),
smsOptOut: z.boolean().optional(),
doNotContact: z.boolean().optional(),
});
// Create contact directly (manual add)
export const createContactSchema = z.object({
displayName: z.string().min(1).max(200),
firstName: z.string().max(100).optional(),
lastName: z.string().max(100).optional(),
email: z.string().email().optional().or(z.literal('')),
phone: z.string().max(30).optional(),
tags: z.array(z.string()).optional(),
notes: z.string().optional(),
supportLevel: z.enum(['LEVEL_1', 'LEVEL_2', 'LEVEL_3', 'LEVEL_4']).optional(),
});
// Create connection
export const createConnectionSchema = z.object({
toPersonType: z.enum(['user', 'addr', 'contact']),
toPersonId: z.string(),
type: z.enum(['HOUSEHOLD', 'FAMILY', 'COLLEAGUE', 'REFERRED_BY', 'CUSTOM']),
label: z.string().optional(),
notes: z.string().optional(),
isBidirectional: z.boolean().default(true),
});
// Merge
export const mergeContactSchema = z.object({
sourceType: z.enum(['user', 'addr', 'contact', 'donor', 'cemail', 'signup', 'sms']),
sourceId: z.string(),
keepFields: z.record(z.enum(['source', 'target'])).optional(),
});
// Graph query
export const graphQuerySchema = z.object({
center: z.string().optional(),
depth: z.coerce.number().int().min(1).max(3).default(2),
source: z.enum(['USER', 'ADDRESS_OCCUPANT', 'CAMPAIGN_SENDER', 'SHIFT_SIGNUP', 'SMS_CONTACT', 'DONATION', 'MANUAL']).optional(),
tag: z.string().optional(),
minScore: z.coerce.number().int().min(0).max(100).optional(),
});
// Activity list
export const activityListSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(50).default(20),
type: z.enum(['EMAIL_SENT', 'RESPONSE_SUBMITTED', 'SHIFT_SIGNUP', 'CANVASS_VISIT', 'DONATION', 'PURCHASE', 'SMS_SENT', 'SMS_RECEIVED', 'VIDEO_VIEW', 'NOTE_ADDED', 'CONTACT_MERGED']).optional(),
});
// Create user account from a Contact
export const createUserFromContactSchema = z.object({
password: z.string().min(12).regex(/[A-Z]/, 'Must contain uppercase').regex(/[a-z]/, 'Must contain lowercase').regex(/[0-9]/, 'Must contain digit'),
role: z.enum(['USER', 'TEMP', 'MAP_ADMIN', 'INFLUENCE_ADMIN', 'SUPER_ADMIN']).default('USER'),
sendWelcomeEmail: z.boolean().default(false),
});
export type CreateUserFromContactInput = z.infer<typeof createUserFromContactSchema>;
// Add address to a contact (with optional map integration)
export const addContactAddressSchema = z.object({
address: z.string().min(1).max(500),
unitNumber: z.string().max(20).optional(),
isPrimary: z.boolean().default(false),
addToMap: z.boolean().default(true),
});
export const removeContactAddressSchema = z.object({
contactAddressId: z.string(),
});
// Add email to a contact
export const addContactEmailSchema = z.object({
email: z.string().email(),
label: z.string().max(50).optional(),
isPrimary: z.boolean().optional(),
});
// Add phone to a contact
export const addContactPhoneSchema = z.object({
phone: z.string().min(1).max(30),
label: z.string().max(50).optional(),
isPrimary: z.boolean().optional(),
});
// Generate profile link with optional expiration + password
export const generateProfileLinkSchema = z.object({
expiresIn: z.enum(['24h', '7d', '30d', '90d', '1y', 'never']).default('never'),
password: z.string().min(4).max(128).optional().nullable(),
});
// Update profile link settings (without regenerating token)
export const updateProfileLinkSchema = z.object({
expiresIn: z.enum(['24h', '7d', '30d', '90d', '1y', 'never']).optional(),
password: z.string().min(4).max(128).optional().nullable(),
removePassword: z.boolean().optional(),
});
export type GenerateProfileLinkInput = z.infer<typeof generateProfileLinkSchema>;
export type UpdateProfileLinkInput = z.infer<typeof updateProfileLinkSchema>;
export type AddContactAddressInput = z.infer<typeof addContactAddressSchema>;
export type AddContactEmailInput = z.infer<typeof addContactEmailSchema>;
export type AddContactPhoneInput = z.infer<typeof addContactPhoneSchema>;
export type ListPeopleInput = z.infer<typeof listPeopleSchema>;
export type ManagePersonInput = z.infer<typeof managePersonSchema>;
export type CreateContactInput = z.infer<typeof createContactSchema>;
export type UpdateContactInput = z.infer<typeof updateContactSchema>;
export type CreateConnectionInput = z.infer<typeof createConnectionSchema>;
export type MergeContactInput = z.infer<typeof mergeContactSchema>;
export type GraphQueryInput = z.infer<typeof graphQuerySchema>;
export type ActivityListInput = z.infer<typeof activityListSchema>;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,254 @@
import { Router, Request, Response, NextFunction } from 'express';
import multer from 'multer';
import { validate } from '../../middleware/validate';
import { optionalAuth } from '../../middleware/auth.middleware';
import { profileViewRateLimit, profileEditRateLimit, profilePhotoRateLimit, profilePasswordRateLimit } from '../../middleware/rate-limit';
import { profileService } from './profile.service';
import { profileSelfUpdateSchema, profileActivitySchema, profilePasswordSchema } from './profile-public.schemas';
const router = Router();
// Profile tokens are crypto.randomBytes(32).toString('hex') → exactly 64 hex chars
const TOKEN_RE = /^[0-9a-f]{64}$/;
// Multer for cover photo upload — memory storage, 5MB limit
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowed.includes(file.mimetype)) {
cb(new Error('Invalid file type. Allowed: JPEG, PNG, WebP'));
return;
}
cb(null, true);
},
});
// ---------------------------------------------------------------------------
// GET /api/profile/:token — Get contact profile (public)
// Checks expiration + password protection before returning profile
// ---------------------------------------------------------------------------
router.get(
'/:token',
profileViewRateLimit,
optionalAuth,
async (req: Request, res: Response, next: NextFunction) => {
try {
const token = req.params.token as string;
if (!token || !TOKEN_RE.test(token)) {
res.status(400).json({ error: { message: 'Invalid token', code: 'INVALID_TOKEN' } });
return;
}
// Pre-check access (expiration + password)
const access = await profileService.validateProfileAccess(token);
if (access.status === 'not_found') {
res.status(404).json({ error: { message: 'Profile not found', code: 'NOT_FOUND' } });
return;
}
if (access.status === 'expired') {
res.status(410).json({
error: { message: 'This profile link has expired', code: 'LINK_EXPIRED' },
expiresAt: access.expiresAt.toISOString(),
});
return;
}
if (access.status === 'password_required') {
res.status(401).json({
error: { message: 'Password required', code: 'PASSWORD_REQUIRED' },
branding: access.branding,
});
return;
}
// Access OK — return full profile (pass viewer ID for isOwnProfile check)
const profile = await profileService.getProfileByToken(token, req.user?.id);
if (!profile) {
res.status(404).json({ error: { message: 'Profile not found', code: 'NOT_FOUND' } });
return;
}
res.json(profile);
} catch (err) {
next(err);
}
},
);
// ---------------------------------------------------------------------------
// POST /api/profile/:token/verify — Verify password for protected profile
// ---------------------------------------------------------------------------
router.post(
'/:token/verify',
profilePasswordRateLimit,
validate(profilePasswordSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const token = req.params.token as string;
if (!token || !TOKEN_RE.test(token)) {
res.status(400).json({ error: { message: 'Invalid token', code: 'INVALID_TOKEN' } });
return;
}
const result = await profileService.verifyProfilePassword(token, req.body.password);
switch (result.status) {
case 'not_found':
res.status(404).json({ error: { message: 'Profile not found', code: 'NOT_FOUND' } });
return;
case 'expired':
res.status(410).json({
error: { message: 'This profile link has expired', code: 'LINK_EXPIRED' },
expiresAt: result.expiresAt.toISOString(),
});
return;
case 'invalid_password':
res.status(401).json({ error: { message: 'Incorrect password', code: 'INVALID_PASSWORD' } });
return;
case 'ok':
res.json(result.profile);
return;
}
} catch (err) {
next(err);
}
},
);
// ---------------------------------------------------------------------------
// PUT /api/profile/:token — Update self-editable fields
// ---------------------------------------------------------------------------
router.put(
'/:token',
profileEditRateLimit,
validate(profileSelfUpdateSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const token = req.params.token as string;
if (!token || !TOKEN_RE.test(token)) {
res.status(400).json({ error: { message: 'Invalid token', code: 'INVALID_TOKEN' } });
return;
}
const updated = await profileService.updateProfileSelfService(token, req.body);
if (!updated) {
res.status(404).json({ error: { message: 'Profile not found', code: 'NOT_FOUND' } });
return;
}
res.json({ success: true });
} catch (err) {
next(err);
}
},
);
// ---------------------------------------------------------------------------
// POST /api/profile/:token/photo — Upload cover photo
// ---------------------------------------------------------------------------
router.post(
'/:token/photo',
profilePhotoRateLimit,
upload.single('photo'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const token = req.params.token as string;
if (!token || !TOKEN_RE.test(token)) {
res.status(400).json({ error: { message: 'Invalid token', code: 'INVALID_TOKEN' } });
return;
}
if (!req.file) {
res.status(400).json({ error: { message: 'No file provided', code: 'NO_FILE' } });
return;
}
const result = await profileService.uploadCoverPhoto(
token,
req.file.buffer,
req.file.mimetype,
req.file.originalname,
);
if (!result) {
res.status(404).json({ error: { message: 'Profile not found', code: 'NOT_FOUND' } });
return;
}
res.json(result);
} catch (err) {
if (err instanceof Error && (
err.message.includes('Invalid file type') ||
err.message.includes('File too large') ||
err.message.includes('Invalid image file')
)) {
res.status(400).json({ error: { message: err.message, code: 'INVALID_FILE' } });
return;
}
next(err);
}
},
);
// ---------------------------------------------------------------------------
// GET /api/profile/:token/photo — Serve cover photo
// ---------------------------------------------------------------------------
router.get(
'/:token/photo',
profileViewRateLimit,
async (req: Request, res: Response, next: NextFunction) => {
try {
const token = req.params.token as string;
if (!token || !TOKEN_RE.test(token)) {
res.status(400).json({ error: { message: 'Invalid token', code: 'INVALID_TOKEN' } });
return;
}
const size = req.query.size === 'thumb' ? 'thumb' : 'cover';
const result = await profileService.serveCoverPhoto(token, size as 'cover' | 'thumb');
if (!result) {
res.status(404).json({ error: { message: 'Photo not found', code: 'NOT_FOUND' } });
return;
}
res.setHeader('Content-Type', result.contentType);
res.setHeader('Cache-Control', 'public, max-age=3600');
result.stream.pipe(res);
} catch (err) {
next(err);
}
},
);
// ---------------------------------------------------------------------------
// GET /api/profile/:token/activity — Activity timeline (filtered for public)
// ---------------------------------------------------------------------------
router.get(
'/:token/activity',
profileViewRateLimit,
validate(profileActivitySchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const token = req.params.token as string;
if (!token || !TOKEN_RE.test(token)) {
res.status(400).json({ error: { message: 'Invalid token', code: 'INVALID_TOKEN' } });
return;
}
const result = await profileService.getProfileActivity(token, req.query as any);
if (!result) {
res.status(404).json({ error: { message: 'Profile not found', code: 'NOT_FOUND' } });
return;
}
res.json(result);
} catch (err) {
next(err);
}
},
);
export { router as profilePublicRouter };

View File

@@ -0,0 +1,33 @@
import { z } from 'zod';
// Token param validation
export const profileTokenSchema = z.object({
token: z.string().min(32).max(128),
});
// Self-update — only these fields are editable by the contact
export const profileSelfUpdateSchema = z.object({
displayName: z.string().min(1).max(200).optional(),
firstName: z.string().max(100).optional().nullable(),
lastName: z.string().max(100).optional().nullable(),
email: z.string().email().max(200).optional().nullable().or(z.literal('')),
phone: z.string().max(30).optional().nullable(),
emailOptOut: z.boolean().optional(),
smsOptOut: z.boolean().optional(),
address: z.string().min(1).max(500).optional().nullable().or(z.literal('')),
});
// Activity query params
export const profileActivitySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(50).default(20),
});
// Password verification for protected profile links
export const profilePasswordSchema = z.object({
password: z.string().min(1).max(128),
});
export type ProfilePasswordInput = z.infer<typeof profilePasswordSchema>;
export type ProfileSelfUpdateInput = z.infer<typeof profileSelfUpdateSchema>;
export type ProfileActivityInput = z.infer<typeof profileActivitySchema>;

View File

@@ -0,0 +1,690 @@
import crypto from 'crypto';
import path from 'path';
import fs from 'fs/promises';
import { createReadStream, existsSync } from 'fs';
import type { ReadStream } from 'fs';
import bcrypt from 'bcryptjs';
import sharp from 'sharp';
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { emailService } from '../../services/email.service';
import { siteSettingsService } from '../settings/settings.service';
import { env } from '../../config/env';
import type { ProfileSelfUpdateInput, ProfileActivityInput } from './profile-public.schemas';
import type { GenerateProfileLinkInput, UpdateProfileLinkInput } from './people.schemas';
const UPLOAD_DIR = '/app/uploads/profile-photos';
const COVER_WIDTH = 800;
const COVER_HEIGHT = 400;
const THUMB_WIDTH = 200;
const THUMB_HEIGHT = 100;
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
// Activity types that are safe to show to the contact themselves
const PUBLIC_ACTIVITY_TYPES = [
'EMAIL_SENT',
'RESPONSE_SUBMITTED',
'SHIFT_SIGNUP',
'CANVASS_VISIT',
'DONATION',
'PURCHASE',
'VIDEO_VIEW',
'PROFILE_SELF_EDIT',
'PROFILE_PHOTO_UPDATED',
];
// Fields that should NOT be returned to the public profile
const HIDDEN_FIELDS = [
'notes',
'doNotContact',
'signRequested',
'mergedIntoId',
'createdByUserId',
] as const;
const BCRYPT_SALT_ROUNDS = 12;
/** Map duration enum → milliseconds */
const DURATION_MS: Record<string, number> = {
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000,
'90d': 90 * 24 * 60 * 60 * 1000,
'1y': 365 * 24 * 60 * 60 * 1000,
};
type ValidContactResult =
| { status: 'not_found' }
| { status: 'expired'; contact: { id: string; profileTokenExpiresAt: Date } }
| { status: 'ok'; contact: NonNullable<Awaited<ReturnType<typeof prisma.contact.findUnique>>> };
type Branding = {
organizationName: string;
organizationShortName: string;
organizationLogoUrl: string | null;
publicColorPrimary: string;
publicColorBgBase: string;
publicColorBgContainer: string;
publicHeaderGradient: string;
};
class ProfileService {
/**
* Shared helper: look up contact by token + check merged + check expiration
*/
private async getValidContact(token: string): Promise<ValidContactResult> {
const contact = await prisma.contact.findUnique({
where: { profileToken: token },
});
if (!contact || contact.mergedIntoId) {
return { status: 'not_found' };
}
if (contact.profileTokenExpiresAt && contact.profileTokenExpiresAt < new Date()) {
return { status: 'expired', contact: { id: contact.id, profileTokenExpiresAt: contact.profileTokenExpiresAt } };
}
return { status: 'ok', contact };
}
/**
* Public pre-check for access control: returns discriminated union for route handler
*/
async validateProfileAccess(token: string): Promise<
| { status: 'ok' }
| { status: 'not_found' }
| { status: 'expired'; expiresAt: Date }
| { status: 'password_required'; branding: Branding }
> {
const result = await this.getValidContact(token);
if (result.status === 'not_found') return { status: 'not_found' };
if (result.status === 'expired') {
return { status: 'expired', expiresAt: result.contact.profileTokenExpiresAt };
}
// Check password protection
if (result.contact.profilePasswordHash) {
const settings = await siteSettingsService.get();
return {
status: 'password_required',
branding: {
organizationName: settings.organizationName,
organizationShortName: settings.organizationShortName,
organizationLogoUrl: settings.organizationLogoUrl,
publicColorPrimary: settings.publicColorPrimary,
publicColorBgBase: settings.publicColorBgBase,
publicColorBgContainer: settings.publicColorBgContainer,
publicHeaderGradient: settings.publicHeaderGradient,
},
};
}
return { status: 'ok' };
}
/**
* Verify password for a protected profile link
* Returns the full profile on success, null on wrong password, or status string for errors
*/
async verifyProfilePassword(token: string, password: string): Promise<
| { status: 'ok'; profile: NonNullable<Awaited<ReturnType<ProfileService['getProfileByToken']>>> }
| { status: 'invalid_password' }
| { status: 'not_found' }
| { status: 'expired'; expiresAt: Date }
> {
const result = await this.getValidContact(token);
if (result.status === 'not_found') return { status: 'not_found' };
if (result.status === 'expired') {
return { status: 'expired', expiresAt: result.contact.profileTokenExpiresAt };
}
if (!result.contact.profilePasswordHash) {
// No password required — just return the profile
const profile = await this.getProfileByToken(token);
if (!profile) return { status: 'not_found' };
return { status: 'ok', profile };
}
const valid = await bcrypt.compare(password, result.contact.profilePasswordHash);
if (!valid) return { status: 'invalid_password' };
const profile = await this.getProfileByToken(token);
if (!profile) return { status: 'not_found' };
return { status: 'ok', profile };
}
/**
* Look up a contact by their profile token, returning safe public data.
* If viewerUserId is provided and matches contact.userId, adds isOwnProfile + enableSocial flags.
*/
async getProfileByToken(token: string, viewerUserId?: string) {
const result = await this.getValidContact(token);
if (result.status !== 'ok') return null;
const contact = result.contact;
// Get org branding for the profile page header
const settings = await siteSettingsService.get();
const branding = {
organizationName: settings.organizationName,
organizationShortName: settings.organizationShortName,
organizationLogoUrl: settings.organizationLogoUrl,
publicColorPrimary: settings.publicColorPrimary,
publicColorBgBase: settings.publicColorBgBase,
publicColorBgContainer: settings.publicColorBgContainer,
publicHeaderGradient: settings.publicHeaderGradient,
};
// Build engagement summary (reuse people service pattern)
const engagement = await this.getPublicEngagementSummary(contact.id, contact.email, contact.phone);
// Look up primary address
const primaryLink = await prisma.contactAddress.findFirst({
where: { contactId: contact.id, isPrimary: true },
include: { address: { include: { location: { select: { address: true } } } } },
});
const primaryAddress = primaryLink?.address?.location?.address || null;
// Check if the authenticated viewer owns this contact profile
const isOwnProfile = viewerUserId ? contact.userId === viewerUserId : false;
// Return sanitized profile — strip hidden fields
return {
id: contact.id,
displayName: contact.displayName,
firstName: contact.firstName,
lastName: contact.lastName,
email: contact.email,
phone: contact.phone,
primaryAddress,
tags: contact.tags as string[],
supportLevel: contact.supportLevel,
primarySource: contact.primarySource,
emailOptOut: contact.emailOptOut,
smsOptOut: contact.smsOptOut,
coverPhotoPath: contact.coverPhotoPath ? true : false, // Boolean only — don't expose path
engagementScore: engagement.score,
engagement,
branding,
isOwnProfile,
enableSocial: settings.enableSocial ?? false,
};
}
/**
* Look up a contact by ID (admin preview) — same shape as public profile + profileToken
*/
async getProfileByContactId(contactId: string) {
const contact = await prisma.contact.findUnique({
where: { id: contactId },
});
if (!contact || contact.mergedIntoId) {
return null;
}
const settings = await siteSettingsService.get();
const branding = {
organizationName: settings.organizationName,
organizationShortName: settings.organizationShortName,
organizationLogoUrl: settings.organizationLogoUrl,
publicColorPrimary: settings.publicColorPrimary,
publicColorBgBase: settings.publicColorBgBase,
publicColorBgContainer: settings.publicColorBgContainer,
publicHeaderGradient: settings.publicHeaderGradient,
};
const engagement = await this.getPublicEngagementSummary(contact.id, contact.email, contact.phone);
// Look up primary address
const primaryLink = await prisma.contactAddress.findFirst({
where: { contactId: contact.id, isPrimary: true },
include: { address: { include: { location: { select: { address: true } } } } },
});
const primaryAddress = primaryLink?.address?.location?.address || null;
return {
id: contact.id,
displayName: contact.displayName,
firstName: contact.firstName,
lastName: contact.lastName,
email: contact.email,
phone: contact.phone,
primaryAddress,
tags: contact.tags as string[],
supportLevel: contact.supportLevel,
primarySource: contact.primarySource,
emailOptOut: contact.emailOptOut,
smsOptOut: contact.smsOptOut,
coverPhotoPath: contact.coverPhotoPath ? true : false,
engagementScore: engagement.score,
engagement,
branding,
profileToken: contact.profileToken,
};
}
/**
* Serve cover photo by contact ID (admin preview)
*/
serveCoverPhotoByContactId(contactId: string, size: 'cover' | 'thumb' = 'cover'): { stream: ReadStream; contentType: string } | null {
const suffix = size === 'thumb' ? '-thumb.jpg' : '-cover.jpg';
const filePath = path.join(UPLOAD_DIR, `${contactId}${suffix}`);
if (!existsSync(filePath)) return null;
return {
stream: createReadStream(filePath),
contentType: 'image/jpeg',
};
}
/**
* Update only the self-editable fields
*/
async updateProfileSelfService(token: string, data: ProfileSelfUpdateInput) {
const result = await this.getValidContact(token);
if (result.status !== 'ok') return null;
const contact = result.contact;
// Build update payload — only self-editable fields
const updateData: Record<string, unknown> = {};
if (data.displayName !== undefined) updateData.displayName = data.displayName;
if (data.firstName !== undefined) updateData.firstName = data.firstName || null;
if (data.lastName !== undefined) updateData.lastName = data.lastName || null;
if (data.email !== undefined) updateData.email = data.email || null;
if (data.phone !== undefined) updateData.phone = data.phone || null;
if (data.emailOptOut !== undefined) updateData.emailOptOut = data.emailOptOut;
if (data.smsOptOut !== undefined) updateData.smsOptOut = data.smsOptOut;
const hasAddressUpdate = !!(data.address && data.address.trim());
if (Object.keys(updateData).length === 0 && !hasAddressUpdate) {
return contact;
}
let updated = contact;
if (Object.keys(updateData).length > 0) {
updateData.lastSelfEditAt = new Date();
updated = await prisma.contact.update({
where: { id: contact.id },
data: updateData,
});
}
// Log activity
await prisma.contactActivity.create({
data: {
contactId: contact.id,
type: 'PROFILE_SELF_EDIT',
title: 'Profile updated via self-service',
description: `Fields updated: ${Object.keys(data).filter(k => data[k as keyof ProfileSelfUpdateInput] !== undefined).join(', ')}`,
occurredAt: new Date(),
},
});
// Handle address update via peopleService (geocodes + creates Location/Address/ContactAddress)
if (data.address && data.address.trim()) {
try {
const { peopleService } = await import('./people.service');
await peopleService.addAddress(
contact.id,
{ address: data.address.trim(), isPrimary: true, addToMap: true },
contact.userId,
);
} catch (err) {
logger.warn(`Self-service address update failed for contact ${contact.id}`, err);
}
}
return updated;
}
/**
* Upload + process a cover photo: resize to 800x400 cover + 200x100 thumb
*/
async uploadCoverPhoto(
token: string,
fileBuffer: Buffer,
mimeType: string,
originalName: string,
) {
const result = await this.getValidContact(token);
if (result.status !== 'ok') return null;
const contact = result.contact;
// Validate MIME type
if (!ALLOWED_MIME_TYPES.includes(mimeType)) {
throw new Error('Invalid file type. Allowed: JPEG, PNG, WebP');
}
// Validate file size
if (fileBuffer.length > MAX_FILE_SIZE) {
throw new Error('File too large. Maximum size: 5MB');
}
// Validate with sharp (also strips EXIF)
try {
await sharp(fileBuffer).metadata();
} catch {
throw new Error('Invalid image file');
}
// Ensure upload directory exists
await fs.mkdir(UPLOAD_DIR, { recursive: true });
const coverPath = path.join(UPLOAD_DIR, `${contact.id}-cover.jpg`);
const thumbPath = path.join(UPLOAD_DIR, `${contact.id}-thumb.jpg`);
// Process: resize + auto-orient + strip all EXIF (sharp strips metadata by default)
await sharp(fileBuffer)
.resize(COVER_WIDTH, COVER_HEIGHT, { fit: 'cover', position: 'centre' })
.rotate() // Auto-rotate based on EXIF before strip
.jpeg({ quality: 85 })
.toFile(coverPath);
await sharp(fileBuffer)
.resize(THUMB_WIDTH, THUMB_HEIGHT, { fit: 'cover', position: 'centre' })
.rotate()
.jpeg({ quality: 75 })
.toFile(thumbPath);
// Update contact record
await prisma.contact.update({
where: { id: contact.id },
data: {
coverPhotoPath: coverPath,
lastSelfEditAt: new Date(),
},
});
// Log activity — sanitize user-supplied filename before storage
const sanitizedName = originalName.replace(/[<>"'&]/g, '').slice(0, 200);
await prisma.contactActivity.create({
data: {
contactId: contact.id,
type: 'PROFILE_PHOTO_UPDATED',
title: 'Cover photo updated via self-service',
description: `Uploaded: ${sanitizedName}`,
occurredAt: new Date(),
},
});
return { success: true };
}
/**
* Serve cover photo file as a read stream
*/
async serveCoverPhoto(token: string, size: 'cover' | 'thumb' = 'cover'): Promise<{ stream: ReadStream; contentType: string } | null> {
const result = await this.getValidContact(token);
if (result.status !== 'ok') return null;
const contact = result.contact;
if (!contact.coverPhotoPath) return null;
const suffix = size === 'thumb' ? '-thumb.jpg' : '-cover.jpg';
const filePath = path.join(UPLOAD_DIR, `${contact.id}${suffix}`);
if (!existsSync(filePath)) return null;
return {
stream: createReadStream(filePath),
contentType: 'image/jpeg',
};
}
/**
* Get paginated activity timeline (filtered for public-safe types only)
*/
async getProfileActivity(token: string, params: ProfileActivityInput) {
const result = await this.getValidContact(token);
if (result.status !== 'ok') return null;
const contact = result.contact;
const where = {
contactId: contact.id,
type: { in: PUBLIC_ACTIVITY_TYPES as any },
};
const [activities, total] = await Promise.all([
prisma.contactActivity.findMany({
where,
orderBy: { occurredAt: 'desc' },
skip: (params.page - 1) * params.limit,
take: params.limit,
}),
prisma.contactActivity.count({ where }),
]);
return {
activities,
pagination: {
page: params.page,
limit: params.limit,
total,
totalPages: Math.ceil(total / params.limit),
},
};
}
/**
* Generate a new profile token for a contact, with optional expiration + password
*/
async generateProfileToken(contactId: string, options?: GenerateProfileLinkInput) {
const token = crypto.randomBytes(32).toString('hex');
// Compute expiration date
let expiresAt: Date | null = null;
const duration = options?.expiresIn || 'never';
if (duration !== 'never' && DURATION_MS[duration]) {
expiresAt = new Date(Date.now() + DURATION_MS[duration]);
}
// Hash password if provided
let passwordHash: string | null = null;
if (options?.password) {
passwordHash = await bcrypt.hash(options.password, BCRYPT_SALT_ROUNDS);
}
const contact = await prisma.contact.update({
where: { id: contactId },
data: {
profileToken: token,
profileTokenExpiresAt: expiresAt,
profilePasswordHash: passwordHash,
},
});
return {
token: contact.profileToken!,
url: this.buildProfileUrl(contact.profileToken!),
expiresAt: contact.profileTokenExpiresAt?.toISOString() || null,
hasPassword: !!contact.profilePasswordHash,
};
}
/**
* Regenerate profile token (invalidates old one)
*/
async regenerateProfileToken(contactId: string, options?: GenerateProfileLinkInput) {
return this.generateProfileToken(contactId, options);
}
/**
* Revoke profile token — clears token, expiration, and password hash
*/
async revokeProfileToken(contactId: string) {
await prisma.contact.update({
where: { id: contactId },
data: {
profileToken: null,
profileTokenExpiresAt: null,
profilePasswordHash: null,
},
});
}
/**
* Update profile link settings (expiration/password) without regenerating the token
*/
async updateProfileLinkSettings(contactId: string, options: UpdateProfileLinkInput) {
const contact = await prisma.contact.findUnique({ where: { id: contactId } });
if (!contact || !contact.profileToken) {
throw new Error('Contact not found or has no profile link');
}
const data: Record<string, unknown> = {};
// Update expiration
if (options.expiresIn !== undefined) {
if (options.expiresIn === 'never') {
data.profileTokenExpiresAt = null;
} else if (DURATION_MS[options.expiresIn]) {
data.profileTokenExpiresAt = new Date(Date.now() + DURATION_MS[options.expiresIn]);
}
}
// Update password
if (options.removePassword) {
data.profilePasswordHash = null;
} else if (options.password) {
data.profilePasswordHash = await bcrypt.hash(options.password, BCRYPT_SALT_ROUNDS);
}
if (Object.keys(data).length === 0) {
return {
token: contact.profileToken,
url: this.buildProfileUrl(contact.profileToken),
expiresAt: contact.profileTokenExpiresAt?.toISOString() || null,
hasPassword: !!contact.profilePasswordHash,
};
}
const updated = await prisma.contact.update({
where: { id: contactId },
data,
});
return {
token: updated.profileToken!,
url: this.buildProfileUrl(updated.profileToken!),
expiresAt: updated.profileTokenExpiresAt?.toISOString() || null,
hasPassword: !!updated.profilePasswordHash,
};
}
/**
* Send profile link via email to the contact
*/
async sendProfileLink(contactId: string, adminUserId: string) {
const contact = await prisma.contact.findUnique({ where: { id: contactId } });
if (!contact) throw new Error('Contact not found');
if (!contact.email) throw new Error('Contact has no email address');
// Generate token if not already present
if (!contact.profileToken) {
await this.generateProfileToken(contactId);
}
const freshContact = await prisma.contact.findUnique({ where: { id: contactId } });
if (!freshContact?.profileToken) throw new Error('Failed to generate profile token');
const profileUrl = this.buildProfileUrl(freshContact.profileToken);
const settings = await siteSettingsService.get();
// Build extra notes for email
const extraNotes: string[] = [];
if (freshContact.profileTokenExpiresAt) {
const expiryDate = freshContact.profileTokenExpiresAt.toLocaleDateString('en-CA', {
year: 'numeric', month: 'long', day: 'numeric',
});
extraNotes.push(`This link will expire on ${expiryDate}.`);
}
if (freshContact.profilePasswordHash) {
extraNotes.push('This link is password-protected. You will need the password provided separately by the organization.');
}
await emailService.sendProfileLinkEmail({
recipientEmail: freshContact.email!,
recipientName: freshContact.displayName,
profileUrl,
organizationName: settings.organizationName,
extraNotes: extraNotes.length > 0 ? extraNotes.join(' ') : undefined,
});
// Log activity
await prisma.contactActivity.create({
data: {
contactId,
type: 'NOTE_ADDED',
title: 'Profile link sent via email',
description: `Sent by admin to ${freshContact.email}`,
occurredAt: new Date(),
},
});
return { success: true, url: profileUrl };
}
/**
* Build the full profile URL
*/
private buildProfileUrl(token: string): string {
const domain = env.DOMAIN || 'localhost:3000';
const protocol = env.NODE_ENV === 'production' ? 'https' : 'http';
const appSubdomain = env.NODE_ENV === 'production' ? `app.${domain}` : domain;
return `${protocol}://${appSubdomain}/profile/${token}`;
}
/**
* Simplified engagement summary for public profile
*/
private async getPublicEngagementSummary(contactId: string, email: string | null, phone: string | null) {
// Count activities by type for this contact
const activityCounts = await prisma.contactActivity.groupBy({
by: ['type'],
where: {
contactId,
type: { in: PUBLIC_ACTIVITY_TYPES as any },
},
_count: { type: true },
});
const countMap = Object.fromEntries(
activityCounts.map((a) => [a.type, a._count.type]),
);
// Simple engagement score calculation
const emailsSent = countMap.EMAIL_SENT || 0;
const responses = countMap.RESPONSE_SUBMITTED || 0;
const shiftSignups = countMap.SHIFT_SIGNUP || 0;
const visits = countMap.CANVASS_VISIT || 0;
const donations = countMap.DONATION || 0;
const videoViews = countMap.VIDEO_VIEW || 0;
const score = Math.min(100,
emailsSent * 5 +
responses * 15 +
shiftSignups * 10 +
visits * 8 +
donations * 20 +
videoViews * 2,
);
return {
score,
emailsSent,
responsesSubmitted: responses,
shiftsSignedUp: shiftSignups,
canvassVisits: visits,
donationCount: donations,
videoViews,
};
}
}
export const profileService = new ProfileService();

View File

@@ -0,0 +1,33 @@
import { z } from 'zod';
// Create a new CRM tag
export const createTagSchema = z.object({
name: z.string().trim().min(1).max(100),
description: z.string().max(500).optional(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Must be a hex color (e.g. #1890ff)').optional(),
syncToListmonk: z.boolean().optional(),
});
// Update an existing CRM tag
export const updateTagSchema = z.object({
name: z.string().trim().min(1).max(100).optional(),
description: z.string().max(500).optional().nullable(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Must be a hex color').optional().nullable(),
});
// Delete tag query params
export const deleteTagQuerySchema = z.object({
removeFromContacts: z.coerce.boolean().optional(),
deleteListmonkList: z.coerce.boolean().optional(),
});
// Bulk tag operations
export const bulkTagSchema = z.object({
tagName: z.string().trim().min(1).max(100),
contactIds: z.array(z.string()).min(1).max(500),
});
export type CreateTagInput = z.infer<typeof createTagSchema>;
export type UpdateTagInput = z.infer<typeof updateTagSchema>;
export type DeleteTagQuery = z.infer<typeof deleteTagQuerySchema>;
export type BulkTagInput = z.infer<typeof bulkTagSchema>;

View File

@@ -0,0 +1,452 @@
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { AppError } from '../../middleware/error-handler';
import { env } from '../../config/env';
import type { Prisma } from '@prisma/client';
import type { CreateTagInput, UpdateTagInput, BulkTagInput } from './tags.schemas';
function generateSlug(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
export const tagsService = {
// =========================================================================
// LIST TAGS
// =========================================================================
async listTags() {
const tags = await prisma.crmTag.findMany({
orderBy: { name: 'asc' },
});
return tags;
},
// =========================================================================
// CREATE TAG
// =========================================================================
async createTag(input: CreateTagInput, createdByUserId: string) {
const slug = generateSlug(input.name);
// Check uniqueness
const existing = await prisma.crmTag.findFirst({
where: { OR: [{ name: input.name }, { slug }] },
});
if (existing) {
throw new AppError(409, `Tag "${input.name}" already exists`, 'TAG_EXISTS');
}
// Count existing contacts with this tag name
const countResult = await prisma.$queryRaw<{ count: bigint }[]>`
SELECT COUNT(*) as count
FROM contacts
WHERE "mergedIntoId" IS NULL
AND tags @> ${JSON.stringify([input.name])}::jsonb
`;
const contactCount = Number(countResult[0]?.count ?? 0);
// Optionally create a Listmonk list for this tag
let listmonkListId: number | null = null;
if (input.syncToListmonk && env.LISTMONK_SYNC_ENABLED === 'true') {
try {
const { listmonkClient } = await import('../../services/listmonk.client');
const list = await listmonkClient.createList(
`CRM: ${input.name}`,
'private',
['v2', 'crm-tag'],
);
listmonkListId = list.id;
logger.info(`Created Listmonk list for CRM tag "${input.name}" (listId=${list.id})`);
} catch (err) {
logger.warn(`Failed to create Listmonk list for tag "${input.name}":`, err);
// Don't fail tag creation if Listmonk is unreachable
}
}
const tag = await prisma.crmTag.create({
data: {
name: input.name,
slug,
description: input.description || null,
color: input.color || null,
listmonkListId,
contactCount,
createdByUserId,
},
});
return tag;
},
// =========================================================================
// UPDATE TAG
// =========================================================================
async updateTag(id: string, input: UpdateTagInput) {
const existing = await prisma.crmTag.findUnique({ where: { id } });
if (!existing) throw new AppError(404, 'Tag not found', 'NOT_FOUND');
const updateData: Prisma.CrmTagUncheckedUpdateInput = {};
if (input.name !== undefined && input.name !== existing.name) {
const newSlug = generateSlug(input.name);
// Check uniqueness of new name/slug
const conflict = await prisma.crmTag.findFirst({
where: {
OR: [{ name: input.name }, { slug: newSlug }],
NOT: { id },
},
});
if (conflict) {
throw new AppError(409, `Tag "${input.name}" already exists`, 'TAG_EXISTS');
}
// Rename tag on all contacts (raw SQL for JSONB manipulation)
await prisma.$executeRaw`
UPDATE contacts
SET tags = (
SELECT jsonb_agg(
CASE WHEN elem = ${existing.name} THEN ${input.name} ELSE elem END
)
FROM jsonb_array_elements_text(tags) AS elem
),
"updatedAt" = NOW()
WHERE tags @> ${JSON.stringify([existing.name])}::jsonb
AND "mergedIntoId" IS NULL
`;
// Rename Listmonk list if linked
if (existing.listmonkListId && env.LISTMONK_SYNC_ENABLED === 'true') {
try {
const { listmonkClient } = await import('../../services/listmonk.client');
await listmonkClient.updateList(existing.listmonkListId, {
name: `CRM: ${input.name}`,
});
} catch (err) {
logger.warn(`Failed to rename Listmonk list for tag "${existing.name}":`, err);
}
}
updateData.name = input.name;
updateData.slug = newSlug;
}
if (input.description !== undefined) updateData.description = input.description;
if (input.color !== undefined) updateData.color = input.color;
const tag = await prisma.crmTag.update({
where: { id },
data: updateData,
});
return tag;
},
// =========================================================================
// DELETE TAG
// =========================================================================
async deleteTag(id: string, options: { removeFromContacts?: boolean; deleteListmonkList?: boolean }) {
const existing = await prisma.crmTag.findUnique({ where: { id } });
if (!existing) throw new AppError(404, 'Tag not found', 'NOT_FOUND');
// Remove tag from all contacts
if (options.removeFromContacts) {
await prisma.$executeRaw`
UPDATE contacts
SET tags = (
SELECT COALESCE(jsonb_agg(elem), '[]'::jsonb)
FROM jsonb_array_elements_text(tags) AS elem
WHERE elem != ${existing.name}
),
"updatedAt" = NOW()
WHERE tags @> ${JSON.stringify([existing.name])}::jsonb
AND "mergedIntoId" IS NULL
`;
}
// Delete Listmonk list
if (options.deleteListmonkList && existing.listmonkListId && env.LISTMONK_SYNC_ENABLED === 'true') {
try {
const { listmonkClient } = await import('../../services/listmonk.client');
await listmonkClient.deleteList(existing.listmonkListId);
logger.info(`Deleted Listmonk list ${existing.listmonkListId} for tag "${existing.name}"`);
} catch (err) {
logger.warn(`Failed to delete Listmonk list for tag "${existing.name}":`, err);
}
}
await prisma.crmTag.delete({ where: { id } });
},
// =========================================================================
// DISCOVER UNREGISTERED TAGS
// =========================================================================
async getUnregisteredTags() {
// Find all distinct tags in use on contacts that aren't in the registry
const results = await prisma.$queryRaw<{ name: string; count: bigint }[]>`
SELECT tag_name AS name, COUNT(*) AS count
FROM contacts, jsonb_array_elements_text(tags) AS tag_name
WHERE "mergedIntoId" IS NULL
AND tag_name NOT IN (SELECT name FROM crm_tags)
GROUP BY tag_name
ORDER BY count DESC, tag_name ASC
`;
return results.map(r => ({ name: r.name, count: Number(r.count) }));
},
// =========================================================================
// REGISTER ALL UNREGISTERED TAGS
// =========================================================================
async registerExistingTags(options: { syncToListmonk?: boolean }, createdByUserId: string) {
const unregistered = await this.getUnregisteredTags();
const created: string[] = [];
for (const { name, count } of unregistered) {
const slug = generateSlug(name);
// Skip if slug conflicts
const conflict = await prisma.crmTag.findFirst({
where: { OR: [{ name }, { slug }] },
});
if (conflict) continue;
let listmonkListId: number | null = null;
if (options.syncToListmonk && env.LISTMONK_SYNC_ENABLED === 'true') {
try {
const { listmonkClient } = await import('../../services/listmonk.client');
const list = await listmonkClient.createList(`CRM: ${name}`, 'private', ['v2', 'crm-tag']);
listmonkListId = list.id;
} catch (err) {
logger.warn(`Failed to create Listmonk list for tag "${name}":`, err);
}
}
await prisma.crmTag.create({
data: {
name,
slug,
listmonkListId,
contactCount: Number(count),
createdByUserId,
},
});
created.push(name);
}
return { registered: created.length, tags: created };
},
// =========================================================================
// BULK ADD TAG
// =========================================================================
async bulkAddTag(input: BulkTagInput) {
const { tagName, contactIds } = input;
// Ensure the tag is registered
let crmTag = await prisma.crmTag.findUnique({ where: { name: tagName } });
// Add tag to all specified contacts (only if they don't already have it)
const updated = await prisma.$executeRaw`
UPDATE contacts
SET tags = tags || ${JSON.stringify([tagName])}::jsonb,
"updatedAt" = NOW()
WHERE id = ANY(${contactIds}::text[])
AND "mergedIntoId" IS NULL
AND NOT (tags @> ${JSON.stringify([tagName])}::jsonb)
`;
// Recalculate count
if (crmTag) {
await this.recalculateCounts(crmTag.id);
}
// Listmonk sync for contacts with email
if (crmTag?.listmonkListId && env.LISTMONK_SYNC_ENABLED === 'true') {
const contacts = await prisma.contact.findMany({
where: { id: { in: contactIds }, email: { not: null }, mergedIntoId: null },
select: { email: true, displayName: true },
});
const { listmonkClient } = await import('../../services/listmonk.client');
for (const c of contacts) {
try {
await listmonkClient.upsertSubscriber(
c.email!,
c.displayName || '',
[crmTag.listmonkListId],
{ source: 'crm_tag_bulk', tag_name: tagName, last_synced: new Date().toISOString() },
);
} catch (err) {
logger.warn(`Failed to sync bulk tag for ${c.email}:`, err);
}
}
}
return { updated: Number(updated) };
},
// =========================================================================
// BULK REMOVE TAG
// =========================================================================
async bulkRemoveTag(input: BulkTagInput) {
const { tagName, contactIds } = input;
// Remove tag from specified contacts
const updated = await prisma.$executeRaw`
UPDATE contacts
SET tags = (
SELECT COALESCE(jsonb_agg(elem), '[]'::jsonb)
FROM jsonb_array_elements_text(tags) AS elem
WHERE elem != ${tagName}
),
"updatedAt" = NOW()
WHERE id = ANY(${contactIds}::text[])
AND "mergedIntoId" IS NULL
AND tags @> ${JSON.stringify([tagName])}::jsonb
`;
// Recalculate count
const crmTag = await prisma.crmTag.findUnique({ where: { name: tagName } });
if (crmTag) {
await this.recalculateCounts(crmTag.id);
}
// Listmonk unsync for contacts with email
if (crmTag?.listmonkListId && env.LISTMONK_SYNC_ENABLED === 'true') {
const contacts = await prisma.contact.findMany({
where: { id: { in: contactIds }, email: { not: null }, mergedIntoId: null },
select: { email: true },
});
const { listmonkClient } = await import('../../services/listmonk.client');
for (const c of contacts) {
try {
const subscriber = await listmonkClient.findSubscriberByEmail(c.email!);
if (subscriber) {
const currentListIds = subscriber.lists.map(l => l.id);
await listmonkClient.removeSubscriberFromLists(
subscriber.id,
[crmTag.listmonkListId],
c.email!,
currentListIds,
);
}
} catch (err) {
logger.warn(`Failed to unsync bulk tag for ${c.email}:`, err);
}
}
}
return { updated: Number(updated) };
},
// =========================================================================
// SYNC TAG TO LISTMONK
// =========================================================================
async syncTagToListmonk(id: string) {
const tag = await prisma.crmTag.findUnique({ where: { id } });
if (!tag) throw new AppError(404, 'Tag not found', 'NOT_FOUND');
if (!tag.listmonkListId) throw new AppError(400, 'Tag is not linked to a Listmonk list', 'NO_LIST');
if (env.LISTMONK_SYNC_ENABLED !== 'true') {
throw new AppError(400, 'Listmonk sync is disabled', 'LISTMONK_DISABLED');
}
const { listmonkClient } = await import('../../services/listmonk.client');
const contacts = await prisma.$queryRaw<{ email: string; displayName: string }[]>`
SELECT email, "displayName"
FROM contacts
WHERE "mergedIntoId" IS NULL
AND email IS NOT NULL
AND tags @> ${JSON.stringify([tag.name])}::jsonb
`;
let synced = 0;
let failed = 0;
for (const c of contacts) {
try {
await listmonkClient.upsertSubscriber(
c.email,
c.displayName || '',
[tag.listmonkListId],
{ source: 'crm_tag_sync', tag_name: tag.name, last_synced: new Date().toISOString() },
);
synced++;
} catch (err) {
failed++;
logger.warn(`Failed to sync tag "${tag.name}" for ${c.email}:`, err);
}
}
return { total: contacts.length, synced, failed };
},
// =========================================================================
// SYNC ALL TAGS TO LISTMONK
// =========================================================================
async syncAllTagsToListmonk() {
if (env.LISTMONK_SYNC_ENABLED !== 'true') {
throw new AppError(400, 'Listmonk sync is disabled', 'LISTMONK_DISABLED');
}
const tags = await prisma.crmTag.findMany({
where: { listmonkListId: { not: null } },
});
const results: { tagName: string; total: number; synced: number; failed: number }[] = [];
for (const tag of tags) {
try {
const result = await this.syncTagToListmonk(tag.id);
results.push({ tagName: tag.name, ...result });
} catch (err) {
logger.warn(`Failed to sync all for tag "${tag.name}":`, err);
results.push({ tagName: tag.name, total: 0, synced: 0, failed: 0 });
}
}
// Also recalculate counts
await this.recalculateCounts();
return { tags: results };
},
// =========================================================================
// RECALCULATE COUNTS
// =========================================================================
async recalculateCounts(tagId?: string) {
const where = tagId ? { id: tagId } : {};
const tags = await prisma.crmTag.findMany({ where });
for (const tag of tags) {
const countResult = await prisma.$queryRaw<{ count: bigint }[]>`
SELECT COUNT(*) as count
FROM contacts
WHERE "mergedIntoId" IS NULL
AND tags @> ${JSON.stringify([tag.name])}::jsonb
`;
await prisma.crmTag.update({
where: { id: tag.id },
data: { contactCount: Number(countResult[0]?.count ?? 0) },
});
}
},
// =========================================================================
// UPDATE TAG COUNTS (increment/decrement helpers for hooks)
// =========================================================================
async updateTagCounts(addedTags: string[], removedTags: string[]) {
if (addedTags.length > 0) {
await prisma.crmTag.updateMany({
where: { name: { in: addedTags } },
data: { contactCount: { increment: 1 } },
});
}
if (removedTags.length > 0) {
await prisma.crmTag.updateMany({
where: { name: { in: removedTags } },
data: { contactCount: { decrement: 1 } },
});
}
},
};

View File

@@ -1,119 +1,52 @@
import { createHmac } from 'crypto';
import { prisma } from '../../config/database';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
import { rocketchatClient } from '../../services/rocketchat.client';
// Changemaker role → Rocket.Chat role mapping
const ROLE_MAP: Record<string, string[]> = {
SUPER_ADMIN: ['admin'],
INFLUENCE_ADMIN: ['moderator'],
MAP_ADMIN: ['moderator'],
USER: ['user'],
TEMP: ['user'],
};
/**
* Generate a deterministic password for a Rocket.Chat user.
* Never exposed to users — only used for RC internal auth.
*/
function generateRCPassword(userId: string): string {
return createHmac('sha256', env.JWT_ACCESS_SECRET)
.update(`rc:${userId}`)
.digest('hex');
}
/**
* Generate a safe username from email, with collision avoidance suffix.
*/
function generateUsername(email: string, suffix = 0): string {
const base = email.split('@')[0].toLowerCase().replace(/[^a-z0-9._-]/g, '');
return suffix > 0 ? `${base}${suffix}` : base;
}
import { rocketchatProvisioner } from '../../services/user-provisioning/rocketchat.provisioner';
import { logger } from '../../utils/logger';
import type { CMUser } from '../../services/user-provisioning/provisioner.interface';
class RocketChatService {
/**
* Get a Rocket.Chat auth token for the given Changemaker user.
* Provisions / syncs the RC user as needed.
* Provisions / syncs the RC user as needed (delegates to provisioner).
*/
async getAuthToken(changemakerUserId: string): Promise<{
authToken: string;
rcUserId: string;
}> {
// 1. Look up Changemaker user
const user = await prisma.user.findUnique({
where: { id: changemakerUserId },
});
const user = await prisma.user.findUnique({ where: { id: changemakerUserId } });
if (!user) throw new Error('User not found');
// 2. Check for cached RC user ID in permissions JSON
const cmUser: CMUser = {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
roles: user.roles,
status: user.status,
permissions: user.permissions as Record<string, unknown> | null,
};
// Check for cached RC user ID
const permissions = (user.permissions as Record<string, unknown>) || {};
let rcUserId = permissions._rcUserId as string | undefined;
if (rcUserId) {
// Sync roles on every access
const rcRoles = ROLE_MAP[user.role] || ['user'];
try {
await rocketchatClient.updateUser(rcUserId, {
name: user.name || user.email.split('@')[0],
roles: rcRoles,
});
} catch (err) {
logger.warn('RC role sync failed, continuing:', err);
if (!rcUserId) {
// Provision via the provisioner framework
const result = await rocketchatProvisioner.provision(cmUser);
if (!result.success || !result.serviceUserId) {
throw new Error(result.error || 'Failed to provision RC user');
}
} else {
// 3. Find or create RC user
let rcUser = await rocketchatClient.findUserByEmail(user.email);
if (!rcUser) {
// Generate unique username with collision handling
let username = generateUsername(user.email);
let suffix = 0;
const maxAttempts = 5;
while (suffix < maxAttempts) {
try {
rcUser = await rocketchatClient.createUser({
email: user.email,
name: user.name || user.email.split('@')[0],
username,
password: generateRCPassword(user.id),
roles: ROLE_MAP[user.role] || ['user'],
});
break;
} catch (err) {
if (err instanceof Error && err.message.includes('already in use')) {
suffix++;
username = generateUsername(user.email, suffix);
} else {
throw err;
}
}
}
if (!rcUser) throw new Error('Failed to create RC user after retries');
}
rcUserId = rcUser._id;
// 4. Cache RC user ID in permissions JSON (no migration needed)
await prisma.user.update({
where: { id: user.id },
data: {
permissions: { ...permissions, _rcUserId: rcUserId },
},
});
rcUserId = result.serviceUserId;
}
// 5. Generate login token
const tokenData = await rocketchatClient.createUserToken(rcUserId);
return {
authToken: tokenData.authToken,
rcUserId: tokenData.userId,
};
// Get auth token (also syncs roles)
const authToken = await rocketchatProvisioner.getAuthToken(cmUser, rcUserId);
if (!authToken) throw new Error('Failed to get RC auth token');
return { authToken, rcUserId };
}
/**
* Setup default channels on first use
*/
/** Setup default channels on first use */
async ensureDefaultChannels(): Promise<void> {
try {
await rocketchatClient.ensureChannel('shifts', 'Shift coordination and updates');

View File

@@ -0,0 +1,37 @@
import { Router, Request, Response, NextFunction } from 'express';
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redis } from '../../config/redis';
import { search } from './search.service';
const router = Router();
const searchRateLimit = rateLimit({
windowMs: 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:search:',
}),
message: { error: { message: 'Too many search requests, please slow down', code: 'SEARCH_RATE_LIMIT_EXCEEDED' } },
});
// GET /api/search?q=<query>&limit=5
router.get('/', searchRateLimit, async (req: Request, res: Response, next: NextFunction) => {
try {
const q = (req.query.q as string || '').trim();
const limit = Math.min(parseInt(req.query.limit as string) || 5, 20);
if (!q || q.length < 2) {
res.json([]);
return;
}
const results = await search(q, limit);
res.json(results);
} catch (err) {
next(err);
}
});
export { router as searchRouter };

View File

@@ -0,0 +1,158 @@
import { prisma } from '../../config/database';
import { redis } from '../../config/redis';
import { siteSettingsService } from '../settings/settings.service';
import { env } from '../../config/env';
export interface SearchResult {
type: 'campaign' | 'shift' | 'page' | 'video' | 'event';
id: string;
title: string;
description: string | null;
link: string;
}
const CACHE_PREFIX = 'search:';
const CACHE_TTL = 60; // 60 seconds
export async function search(query: string, limit = 5): Promise<SearchResult[]> {
const q = query.trim().toLowerCase();
if (!q || q.length < 2) return [];
// Check cache
const cacheKey = `${CACHE_PREFIX}${q}:${limit}`;
try {
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
} catch { /* cache miss */ }
const settings = await siteSettingsService.getPublic();
const results: SearchResult[] = [];
// Fan-out queries (only enabled modules)
const promises: Array<Promise<SearchResult[]>> = [];
if (settings.enableInfluence !== false) {
promises.push(searchCampaigns(q, limit));
}
if (settings.enableMap !== false) {
promises.push(searchShifts(q, limit));
}
if (settings.enableLandingPages !== false) {
promises.push(searchPages(q, limit));
}
if (settings.enableMediaFeatures !== false) {
promises.push(searchMedia(q, limit));
}
const settled = await Promise.allSettled(promises);
for (const result of settled) {
if (result.status === 'fulfilled') {
results.push(...result.value);
}
}
// Sort by relevance (title exact match first) and limit total
results.sort((a, b) => {
const aExact = a.title.toLowerCase().includes(q) ? 0 : 1;
const bExact = b.title.toLowerCase().includes(q) ? 0 : 1;
return aExact - bExact;
});
const limited = results.slice(0, limit * 2); // Allow more total results
// Cache
try {
await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(limited));
} catch { /* non-critical */ }
return limited;
}
async function searchCampaigns(q: string, limit: number): Promise<SearchResult[]> {
const campaigns = await prisma.campaign.findMany({
where: {
status: 'ACTIVE',
OR: [
{ title: { contains: q, mode: 'insensitive' } },
{ description: { contains: q, mode: 'insensitive' } },
],
},
select: { id: true, slug: true, title: true, description: true },
take: limit,
orderBy: { createdAt: 'desc' },
});
return campaigns.map(c => ({
type: 'campaign' as const,
id: c.id,
title: c.title,
description: c.description?.slice(0, 120) ?? null,
link: `/campaign/${c.slug}`,
}));
}
async function searchShifts(q: string, limit: number): Promise<SearchResult[]> {
const shifts = await prisma.shift.findMany({
where: {
status: 'OPEN',
date: { gte: new Date() },
OR: [
{ title: { contains: q, mode: 'insensitive' } },
{ location: { contains: q, mode: 'insensitive' } },
],
},
select: { id: true, title: true, location: true, date: true },
take: limit,
orderBy: { date: 'asc' },
});
return shifts.map(s => ({
type: 'shift' as const,
id: s.id,
title: s.title,
description: s.location,
link: '/shifts',
}));
}
async function searchPages(q: string, limit: number): Promise<SearchResult[]> {
const pages = await prisma.landingPage.findMany({
where: {
published: true,
OR: [
{ title: { contains: q, mode: 'insensitive' } },
{ description: { contains: q, mode: 'insensitive' } },
],
},
select: { slug: true, title: true, description: true },
take: limit,
orderBy: { updatedAt: 'desc' },
});
return pages.map(p => ({
type: 'page' as const,
id: p.slug,
title: p.title,
description: p.description?.slice(0, 120) ?? null,
link: `/p/${p.slug}`,
}));
}
async function searchMedia(q: string, limit: number): Promise<SearchResult[]> {
try {
const videos = await prisma.video.findMany({
where: {
isPublished: true,
title: { contains: q, mode: 'insensitive' },
},
select: { id: true, title: true },
take: limit,
orderBy: { publishedAt: 'desc' },
});
return videos.map(v => ({
type: 'video' as const,
id: String(v.id),
title: v.title ?? 'Untitled video',
description: null,
link: `/gallery/watch/${v.id}`,
}));
} catch {
return []; // Video model might not exist
}
}

View File

@@ -17,7 +17,7 @@ router.get(
'/status',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const [nocodbOnline, n8nOnline, giteaOnline, mailhogOnline, miniqrOnline, excalidrawOnline, homepageOnline, vaultwardenOnline, rocketchatOnline, gancioOnline] = await Promise.all([
const [nocodbOnline, n8nOnline, giteaOnline, mailhogOnline, miniqrOnline, excalidrawOnline, homepageOnline, vaultwardenOnline, rocketchatOnline, gancioOnline, jitsiOnline] = await Promise.all([
isServiceOnline(env.NOCODB_URL),
isServiceOnline(env.N8N_URL),
isServiceOnline(env.GITEA_URL),
@@ -28,6 +28,7 @@ router.get(
isServiceOnline(env.VAULTWARDEN_URL),
isServiceOnline(`${env.ROCKETCHAT_URL}/api/info`),
isServiceOnline(env.GANCIO_URL),
isServiceOnline(env.JITSI_URL),
]);
// Update Prometheus gauges
@@ -41,6 +42,7 @@ router.get(
setServiceUp('vaultwarden', vaultwardenOnline);
setServiceUp('rocketchat', rocketchatOnline);
setServiceUp('gancio', gancioOnline);
setServiceUp('jitsi', jitsiOnline);
res.json({
nocodb: { online: nocodbOnline, url: env.NOCODB_URL },
@@ -53,6 +55,7 @@ router.get(
vaultwarden: { online: vaultwardenOnline, url: env.VAULTWARDEN_URL },
rocketchat: { online: rocketchatOnline, url: env.ROCKETCHAT_URL },
gancio: { online: gancioOnline, url: env.GANCIO_URL },
jitsi: { online: jitsiOnline, url: env.JITSI_URL },
});
} catch (err) {
logger.error('Failed to check services status', err);
@@ -89,10 +92,10 @@ router.get(
mkdocsPort: env.MKDOCS_PORT,
mkdocsSubdomain: 'docs',
// Grafana (metrics visualization)
grafanaPort: 3001,
grafanaPort: env.GRAFANA_EMBED_PORT,
grafanaSubdomain: 'grafana',
// Alertmanager (alert routing)
alertmanagerPort: 9093,
alertmanagerPort: env.ALERTMANAGER_EMBED_PORT,
alertmanagerSubdomain: 'alertmanager',
// Homepage (service dashboard)
homepagePort: env.HOMEPAGE_EMBED_PORT,
@@ -106,6 +109,9 @@ router.get(
// Gancio (event management)
gancioPort: env.GANCIO_EMBED_PORT,
gancioSubdomain: 'events',
// Jitsi Meet (video conferencing)
jitsiPort: env.JITSI_EMBED_PORT,
jitsiSubdomain: 'meet',
});
},
);

View File

@@ -6,6 +6,11 @@ import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { emailService } from '../../services/email.service';
import { giteaClient } from '../../services/gitea.client';
import { gancioSettingsSyncService } from '../../services/gancio-settings-sync.service';
import { headerBuilderService } from '../docs/header-builder.service';
import { mkdocsConfigService } from '../docs/mkdocs-config.service';
import { logger } from '../../utils/logger';
const router = Router();
@@ -99,6 +104,48 @@ router.put(
await emailService.rebuildTransporter();
}
// If Gitea-related fields were updated, invalidate the config cache
const giteaFields = ['enableDocsComments', 'giteaApiToken', 'giteaCommentsRepoOwner', 'giteaCommentsRepoName', 'giteaOauthClientId', 'giteaOauthClientSecret'];
if (giteaFields.some((f) => f in req.body)) {
giteaClient.clearConfigCache();
}
// If Gancio-relevant fields were updated, sync to Gancio (fire-and-forget)
if (gancioSettingsSyncService.hasGancioChanges(req.body)) {
gancioSettingsSyncService.syncChanged(req.body).catch(() => {});
}
// If navConfig or theme colors changed, trigger MkDocs header rebuild + docs build
const headerTriggerFields = ['navConfig', 'publicHeaderGradient', 'publicColorBgBase', 'publicColorBgContainer'];
if (headerTriggerFields.some((f) => f in req.body)) {
if ('navConfig' in req.body) {
gancioSettingsSyncService.syncAll().catch(() => {});
}
// Regenerate MkDocs header from navConfig, then trigger a docs build
const navItems = (settings.navConfig as Record<string, unknown> | null)?.items as unknown[] | undefined;
if (navItems?.length) {
headerBuilderService.regenerateFromNavConfig(
navItems as any[],
{
publicHeaderGradient: settings.publicHeaderGradient ?? undefined,
publicColorBgBase: settings.publicColorBgBase ?? undefined,
publicColorBgContainer: settings.publicColorBgContainer ?? undefined,
},
).then(() => {
// Fire-and-forget docs build so the static site picks up the new header
mkdocsConfigService.triggerBuild()
.then((result) => {
if (result.success) {
logger.info(`MkDocs rebuild after header change completed in ${result.duration}ms`);
} else {
logger.warn(`MkDocs rebuild after header change failed: ${result.output}`);
}
})
.catch(() => {});
}).catch(() => {});
}
}
res.json(settings);
} catch (err) {
next(err);

View File

@@ -51,6 +51,51 @@ export const updateSiteSettingsSchema = z.object({
enableGalleryAds: z.boolean().optional(),
enableChat: z.boolean().optional(),
enableEvents: z.boolean().optional(),
enableDocsComments: z.boolean().optional(),
enableSms: z.boolean().optional(),
enablePeople: z.boolean().optional(),
enableSocial: z.boolean().optional(),
enableMeet: z.boolean().optional(),
autoSyncPeopleToMap: z.boolean().optional(),
// SMS connection config
smsTermuxApiUrl: z.string().max(500).optional(),
smsTermuxApiKey: z.string().max(500).optional(),
smsTailscaleApiKey: z.string().max(500).optional(),
smsTailscaleTailnet: z.string().max(200).optional(),
smsTailscaleDeviceId: z.string().max(200).optional(),
smsTailscaleDeviceName: z.string().max(200).optional(),
// Gitea Docs Comments
giteaApiToken: z.string().max(500).optional(),
giteaCommentsRepoOwner: z.string().max(100).optional(),
giteaCommentsRepoName: z.string().max(100).optional(),
giteaOauthClientId: z.string().max(500).optional(),
giteaOauthClientSecret: z.string().max(500).optional(),
// User Provisioning
enableUserProvisioning: z.boolean().optional(),
provisionGitea: z.boolean().optional(),
provisionGiteaTiming: z.enum(['lazy', 'eager']).optional(),
provisionVaultwarden: z.boolean().optional(),
provisionVaultwardenTiming: z.enum(['lazy', 'eager']).optional(),
provisionListmonk: z.boolean().optional(),
provisionListmonkTiming: z.enum(['lazy', 'eager']).optional(),
// Navigation configuration
navConfig: z.object({
items: z.array(z.object({
id: z.string(),
label: z.string(),
path: z.string(),
icon: z.string(),
enabled: z.boolean(),
order: z.number(),
type: z.enum(['builtin', 'custom']),
featureFlag: z.string().optional(),
external: z.boolean().optional(),
})),
}).optional(),
// Notification settings
notifyAdminShiftSignup: z.boolean().optional(),
@@ -60,6 +105,10 @@ export const updateSiteSettingsSchema = z.object({
notifyVolunteerSessionSummary: z.boolean().optional(),
notifyVolunteerCancellation: z.boolean().optional(),
notifyVolunteerShiftReminder: z.boolean().optional(),
notifyVolunteerShiftThankYou: z.boolean().optional(),
notifyVolunteerReengagement: z.boolean().optional(),
reengagementInactiveDays: z.number().int().min(1).max(365).optional(),
reengagementCooldownDays: z.number().int().min(1).max(365).optional(),
});
export type UpdateSiteSettingsInput = z.infer<typeof updateSiteSettingsSchema>;

View File

@@ -5,10 +5,10 @@ import { encrypt, decrypt } from '../../utils/crypto';
import { env } from '../../config/env';
// Fields to strip from public responses
const SENSITIVE_FIELDS = ['smtpHost', 'smtpPort', 'smtpUser', 'smtpPass', 'smtpFromAddress', 'testEmailRecipient'] as const;
const SENSITIVE_FIELDS = ['smtpHost', 'smtpPort', 'smtpUser', 'smtpPass', 'smtpFromAddress', 'testEmailRecipient', 'giteaApiToken', 'giteaOauthClientSecret', 'smsTermuxApiUrl', 'smsTermuxApiKey', 'smsTailscaleApiKey'] as const;
// Fields that are encrypted at rest in the database
const ENCRYPTED_FIELDS = ['smtpPass'] as const;
const ENCRYPTED_FIELDS = ['smtpPass', 'giteaApiToken', 'giteaOauthClientSecret', 'smsTermuxApiKey', 'smsTailscaleApiKey'] as const;
/** Decrypt encrypted fields on a settings object (mutates in place) */
function decryptSettings(settings: SiteSettings): SiteSettings {

View File

@@ -0,0 +1,108 @@
import { Router } from 'express';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { validate } from '../../../middleware/validate';
import { smsCampaignsService } from './sms-campaigns.service';
import { createSmsCampaignSchema, updateSmsCampaignSchema } from './sms-campaigns.schemas';
import { smsQueueService } from '../../../services/sms-queue.service';
const router = Router();
// All routes require authentication + SUPER_ADMIN or INFLUENCE_ADMIN
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
// GET /api/sms/campaigns — list all campaigns
router.get('/', async (req, res, next) => {
try {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 50));
const result = await smsCampaignsService.findAll(page, limit);
res.json(result);
} catch (err) { next(err); }
});
// GET /api/sms/campaigns/queue-stats — get BullMQ queue stats
router.get('/queue-stats', async (_req, res, next) => {
try {
const stats = await smsQueueService.getStats();
res.json(stats);
} catch (err) { next(err); }
});
// POST /api/sms/campaigns — create a new campaign
router.post('/', validate(createSmsCampaignSchema), async (req, res, next) => {
try {
const campaign = await smsCampaignsService.create(req.body, req.user!.id);
res.status(201).json(campaign);
} catch (err) { next(err); }
});
// GET /api/sms/campaigns/:id — get a single campaign
router.get('/:id', async (req, res, next) => {
try {
const campaign = await smsCampaignsService.findById(req.params.id as string);
if (!campaign) { res.status(404).json({ error: 'Campaign not found' }); return; }
res.json(campaign);
} catch (err) { next(err); }
});
// PUT /api/sms/campaigns/:id — update a campaign
router.put('/:id', validate(updateSmsCampaignSchema), async (req, res, next) => {
try {
const campaign = await smsCampaignsService.update(req.params.id as string, req.body);
res.json(campaign);
} catch (err) { next(err); }
});
// DELETE /api/sms/campaigns/:id — delete a campaign
router.delete('/:id', async (req, res, next) => {
try {
await smsCampaignsService.delete(req.params.id as string);
res.json({ success: true });
} catch (err) { next(err); }
});
// POST /api/sms/campaigns/:id/start — start sending
router.post('/:id/start', async (req, res, next) => {
try {
const result = await smsCampaignsService.start(req.params.id as string);
res.json(result);
} catch (err) { next(err); }
});
// POST /api/sms/campaigns/:id/pause — pause sending
router.post('/:id/pause', async (req, res, next) => {
try {
await smsCampaignsService.pause(req.params.id as string);
res.json({ success: true });
} catch (err) { next(err); }
});
// POST /api/sms/campaigns/:id/resume — resume sending
router.post('/:id/resume', async (req, res, next) => {
try {
const result = await smsCampaignsService.resume(req.params.id as string);
res.json(result);
} catch (err) { next(err); }
});
// GET /api/sms/campaigns/:id/status — live campaign status
router.get('/:id/status', async (req, res, next) => {
try {
const status = await smsCampaignsService.getStatus(req.params.id as string);
res.json(status);
} catch (err) { next(err); }
});
// GET /api/sms/campaigns/:id/recipients — campaign recipients
router.get('/:id/recipients', async (req, res, next) => {
try {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
const statusFilter = req.query.status as string | undefined;
const result = await smsCampaignsService.getRecipients(req.params.id as string, page, limit, statusFilter);
res.json(result);
} catch (err) { next(err); }
});
export const smsCampaignsRouter = router;

View File

@@ -0,0 +1,18 @@
import { z } from 'zod';
export const createSmsCampaignSchema = z.object({
name: z.string().min(1).max(200),
messageTemplate: z.string().min(1).max(1600),
contactListId: z.string().min(1),
advocacyCampaignId: z.string().nullable().optional(),
delayBetweenMs: z.number().int().min(1000).max(60000).default(3000),
});
export const updateSmsCampaignSchema = z.object({
name: z.string().min(1).max(200).optional(),
messageTemplate: z.string().min(1).max(1600).optional(),
delayBetweenMs: z.number().int().min(1000).max(60000).optional(),
});
export type CreateSmsCampaignInput = z.infer<typeof createSmsCampaignSchema>;
export type UpdateSmsCampaignInput = z.infer<typeof updateSmsCampaignSchema>;

View File

@@ -0,0 +1,172 @@
import { prisma } from '../../../config/database';
import { smsQueueService } from '../../../services/sms-queue.service';
import type { CreateSmsCampaignInput, UpdateSmsCampaignInput } from './sms-campaigns.schemas';
export const smsCampaignsService = {
/**
* Create a campaign and copy contact list entries into recipients.
*/
async create(data: CreateSmsCampaignInput, userId?: string) {
// Verify contact list exists
const list = await prisma.smsContactList.findUnique({
where: { id: data.contactListId },
include: { entries: { select: { phone: true, name: true } } },
});
if (!list) throw new Error('Contact list not found');
const campaign = await prisma.smsCampaign.create({
data: {
name: data.name,
messageTemplate: data.messageTemplate,
contactListId: data.contactListId,
advocacyCampaignId: data.advocacyCampaignId || undefined,
delayBetweenMs: data.delayBetweenMs,
totalRecipients: list.entries.length,
createdByUserId: userId,
},
});
// Copy contact list entries into campaign recipients
if (list.entries.length > 0) {
await prisma.smsCampaignRecipient.createMany({
data: list.entries.map((entry) => ({
campaignId: campaign.id,
phone: entry.phone,
name: entry.name,
})),
});
}
return campaign;
},
async findAll(page = 1, limit = 50) {
const skip = (page - 1) * limit;
const [items, total] = await Promise.all([
prisma.smsCampaign.findMany({
orderBy: { createdAt: 'desc' },
skip,
take: limit,
include: {
contactList: { select: { id: true, name: true } },
advocacyCampaign: { select: { id: true, title: true, slug: true } },
createdByUser: { select: { id: true, name: true, email: true } },
},
}),
prisma.smsCampaign.count(),
]);
return { items, total, page, limit };
},
async findById(id: string) {
return prisma.smsCampaign.findUnique({
where: { id },
include: {
contactList: { select: { id: true, name: true, totalContacts: true } },
advocacyCampaign: { select: { id: true, title: true, slug: true } },
createdByUser: { select: { id: true, name: true, email: true } },
_count: { select: { recipients: true, messages: true, conversations: true } },
},
});
},
async update(id: string, data: UpdateSmsCampaignInput) {
const campaign = await prisma.smsCampaign.findUnique({ where: { id }, select: { status: true } });
if (!campaign) throw new Error('Campaign not found');
if (campaign.status !== 'DRAFT') throw new Error('Can only edit campaigns in DRAFT status');
return prisma.smsCampaign.update({ where: { id }, data });
},
async delete(id: string) {
const campaign = await prisma.smsCampaign.findUnique({ where: { id }, select: { status: true } });
if (!campaign) throw new Error('Campaign not found');
if (campaign.status !== 'DRAFT') throw new Error('Can only delete campaigns in DRAFT status');
await prisma.smsCampaign.delete({ where: { id } });
},
/**
* Start campaign: set RUNNING, enqueue all pending recipients.
*/
async start(id: string) {
const campaign = await prisma.smsCampaign.findUnique({ where: { id } });
if (!campaign) throw new Error('Campaign not found');
if (campaign.status !== 'DRAFT' && campaign.status !== 'PAUSED') {
throw new Error('Can only start campaigns in DRAFT or PAUSED status');
}
await prisma.smsCampaign.update({
where: { id },
data: {
status: 'RUNNING',
startedAt: campaign.startedAt || new Date(),
},
});
const enqueued = await smsQueueService.enqueueCampaignRecipients(id, campaign.delayBetweenMs);
return { enqueued };
},
/**
* Pause campaign: set PAUSED. Worker checks status before each message.
*/
async pause(id: string) {
const campaign = await prisma.smsCampaign.findUnique({ where: { id }, select: { status: true } });
if (!campaign) throw new Error('Campaign not found');
if (campaign.status !== 'RUNNING') throw new Error('Can only pause running campaigns');
await prisma.smsCampaign.update({ where: { id }, data: { status: 'PAUSED' } });
},
/**
* Resume campaign: set RUNNING, re-enqueue remaining PENDING recipients.
*/
async resume(id: string) {
const campaign = await prisma.smsCampaign.findUnique({ where: { id } });
if (!campaign) throw new Error('Campaign not found');
if (campaign.status !== 'PAUSED') throw new Error('Can only resume paused campaigns');
await prisma.smsCampaign.update({ where: { id }, data: { status: 'RUNNING' } });
const enqueued = await smsQueueService.enqueueCampaignRecipients(id, campaign.delayBetweenMs);
return { enqueued };
},
/**
* Get live status of a campaign: sent/failed/pending/total counts.
*/
async getStatus(id: string) {
const campaign = await prisma.smsCampaign.findUnique({
where: { id },
select: { status: true, totalRecipients: true, totalSent: true, totalFailed: true, totalResponded: true, startedAt: true, completedAt: true },
});
if (!campaign) throw new Error('Campaign not found');
const pending = await prisma.smsCampaignRecipient.count({ where: { campaignId: id, status: 'PENDING' } });
return {
...campaign,
pending,
};
},
/**
* Get recipients for a campaign (paginated)
*/
async getRecipients(campaignId: string, page = 1, limit = 100, statusFilter?: string) {
const skip = (page - 1) * limit;
const where: Record<string, unknown> = { campaignId };
if (statusFilter) where.status = statusFilter;
const [items, total] = await Promise.all([
prisma.smsCampaignRecipient.findMany({
where,
orderBy: { createdAt: 'asc' },
skip,
take: limit,
}),
prisma.smsCampaignRecipient.count({ where }),
]);
return { items, total, page, limit };
},
};

View File

@@ -0,0 +1,109 @@
import { Router } from 'express';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { validate } from '../../../middleware/validate';
import { smsContactsService } from './sms-contacts.service';
import { createContactListSchema, updateContactListSchema, createContactEntrySchema } from './sms-contacts.schemas';
const router = Router();
// All routes require authentication + SUPER_ADMIN or INFLUENCE_ADMIN
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
// --- Contact Lists ---
// GET /api/sms/contacts — list all contact lists
router.get('/', async (req, res, next) => {
try {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 50));
const result = await smsContactsService.findAll(page, limit);
res.json(result);
} catch (err) { next(err); }
});
// POST /api/sms/contacts — create a new contact list
router.post('/', validate(createContactListSchema), async (req, res, next) => {
try {
const list = await smsContactsService.createList(req.body, req.user!.id);
res.status(201).json(list);
} catch (err) { next(err); }
});
// GET /api/sms/contacts/:id — get a single contact list
router.get('/:id', async (req, res, next) => {
try {
const list = await smsContactsService.findById(req.params.id as string);
if (!list) { res.status(404).json({ error: 'Contact list not found' }); return; }
res.json(list);
} catch (err) { next(err); }
});
// PUT /api/sms/contacts/:id — update a contact list
router.put('/:id', validate(updateContactListSchema), async (req, res, next) => {
try {
const list = await smsContactsService.updateList(req.params.id as string, req.body);
res.json(list);
} catch (err) { next(err); }
});
// DELETE /api/sms/contacts/:id — archive a contact list
router.delete('/:id', async (req, res, next) => {
try {
await smsContactsService.archiveList(req.params.id as string);
res.json({ success: true });
} catch (err) { next(err); }
});
// --- Entries ---
// GET /api/sms/contacts/:id/entries — list entries in a contact list
router.get('/:id/entries', async (req, res, next) => {
try {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
const result = await smsContactsService.getEntries(req.params.id as string, page, limit);
res.json(result);
} catch (err) { next(err); }
});
// POST /api/sms/contacts/:id/entries — add a single entry
router.post('/:id/entries', validate(createContactEntrySchema), async (req, res, next) => {
try {
const entry = await smsContactsService.addEntry(req.params.id as string, req.body);
res.status(201).json(entry);
} catch (err) { next(err); }
});
// DELETE /api/sms/contacts/:id/entries/:entryId — remove an entry
router.delete('/:id/entries/:entryId', async (req, res, next) => {
try {
await smsContactsService.deleteEntry(req.params.entryId as string);
res.json({ success: true });
} catch (err) { next(err); }
});
// --- Import ---
// POST /api/sms/contacts/:id/import-csv — import contacts from CSV text
router.post('/:id/import-csv', async (req, res, next) => {
try {
const { csv, filename } = req.body as { csv?: string; filename?: string };
if (!csv || typeof csv !== 'string') {
res.status(400).json({ error: 'CSV text is required in the "csv" field' });
return;
}
const result = await smsContactsService.importCsv(req.params.id as string, csv, filename);
res.json(result);
} catch (err) { next(err); }
});
// POST /api/sms/contacts/:id/import-phone — import contacts from phone address book
router.post('/:id/import-phone', async (req, res, next) => {
try {
const result = await smsContactsService.importFromPhone(req.params.id as string);
res.json(result);
} catch (err) { next(err); }
});
export const smsContactsRouter = router;

View File

@@ -0,0 +1,20 @@
import { z } from 'zod';
export const createContactListSchema = z.object({
name: z.string().min(1).max(200),
});
export const updateContactListSchema = z.object({
name: z.string().min(1).max(200).optional(),
});
export const createContactEntrySchema = z.object({
phone: z.string().min(7).max(20),
name: z.string().max(200).optional(),
email: z.string().email().max(200).optional(),
customFields: z.record(z.string()).optional(),
});
export type CreateContactListInput = z.infer<typeof createContactListSchema>;
export type UpdateContactListInput = z.infer<typeof updateContactListSchema>;
export type CreateContactEntryInput = z.infer<typeof createContactEntrySchema>;

View File

@@ -0,0 +1,294 @@
import { prisma } from '../../../config/database';
import { Prisma } from '@prisma/client';
import { termuxClient } from '../../../services/termux.client';
import type { CreateContactListInput, UpdateContactListInput, CreateContactEntryInput } from './sms-contacts.schemas';
/**
* Normalize a phone number: strip non-digit characters, validate 10-11 digits.
* Returns null if invalid.
*/
function normalizePhone(raw: string): string | null {
const digits = raw.replace(/\D/g, '');
if (digits.length === 10) return digits;
if (digits.length === 11 && digits.startsWith('1')) return digits;
return null;
}
/**
* Parse CSV text into rows of objects. Supports comma and tab delimiters.
* First row is treated as headers.
*/
function parseCsv(text: string): Record<string, string>[] {
const lines = text.split(/\r?\n/).filter((l) => l.trim());
if (lines.length < 2) return [];
// Detect delimiter (tab vs comma)
const delimiter = lines[0].includes('\t') ? '\t' : ',';
const headers = lines[0].split(delimiter).map((h) => h.trim().replace(/^["']|["']$/g, ''));
const rows: Record<string, string>[] = [];
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(delimiter).map((v) => v.trim().replace(/^["']|["']$/g, ''));
const row: Record<string, string> = {};
headers.forEach((h, idx) => {
row[h] = values[idx] || '';
});
rows.push(row);
}
return rows;
}
/**
* Detect which CSV column contains phone numbers by header name.
*/
function detectPhoneColumn(headers: string[]): string | null {
const patterns = ['phone', 'mobile', 'cell', 'telephone', 'tel', 'number', 'sms'];
const lower = headers.map((h) => h.toLowerCase());
for (const pattern of patterns) {
const idx = lower.findIndex((h) => h.includes(pattern));
if (idx >= 0) return headers[idx];
}
return null;
}
/**
* Detect which CSV column contains names by header name.
*/
function detectNameColumn(headers: string[]): string | null {
const patterns = ['name', 'full_name', 'fullname', 'contact', 'first_name'];
const lower = headers.map((h) => h.toLowerCase());
for (const pattern of patterns) {
const idx = lower.findIndex((h) => h.includes(pattern));
if (idx >= 0) return headers[idx];
}
return null;
}
/**
* Detect which CSV column contains email addresses by header name.
*/
function detectEmailColumn(headers: string[]): string | null {
const patterns = ['email', 'e-mail', 'mail'];
const lower = headers.map((h) => h.toLowerCase());
for (const pattern of patterns) {
const idx = lower.findIndex((h) => h.includes(pattern));
if (idx >= 0) return headers[idx];
}
return null;
}
export const smsContactsService = {
async createList(data: CreateContactListInput, userId?: string) {
return prisma.smsContactList.create({
data: {
name: data.name,
createdByUserId: userId,
},
});
},
async findAll(page = 1, limit = 50) {
const skip = (page - 1) * limit;
const [items, total] = await Promise.all([
prisma.smsContactList.findMany({
where: { status: 'ACTIVE' },
orderBy: { createdAt: 'desc' },
skip,
take: limit,
include: {
createdByUser: { select: { id: true, name: true, email: true } },
_count: { select: { entries: true, campaigns: true } },
},
}),
prisma.smsContactList.count({ where: { status: 'ACTIVE' } }),
]);
return { items, total, page, limit };
},
async findById(id: string) {
return prisma.smsContactList.findUnique({
where: { id },
include: {
createdByUser: { select: { id: true, name: true, email: true } },
_count: { select: { entries: true, campaigns: true } },
},
});
},
async getEntries(listId: string, page = 1, limit = 100) {
const skip = (page - 1) * limit;
const [items, total] = await Promise.all([
prisma.smsContactListEntry.findMany({
where: { listId },
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
prisma.smsContactListEntry.count({ where: { listId } }),
]);
return { items, total, page, limit };
},
async updateList(id: string, data: UpdateContactListInput) {
return prisma.smsContactList.update({
where: { id },
data,
});
},
async archiveList(id: string) {
return prisma.smsContactList.update({
where: { id },
data: { status: 'ARCHIVED' },
});
},
async addEntry(listId: string, data: CreateContactEntryInput) {
const phone = normalizePhone(data.phone);
if (!phone) throw new Error('Invalid phone number');
const entry = await prisma.smsContactListEntry.upsert({
where: { listId_phone: { listId, phone } },
create: {
listId,
phone,
name: data.name,
email: data.email,
customFields: data.customFields as Prisma.InputJsonValue,
},
update: {
name: data.name,
email: data.email,
customFields: data.customFields as Prisma.InputJsonValue,
},
});
// Update total count
const count = await prisma.smsContactListEntry.count({ where: { listId } });
await prisma.smsContactList.update({ where: { id: listId }, data: { totalContacts: count } });
return entry;
},
async deleteEntry(id: string) {
const entry = await prisma.smsContactListEntry.delete({ where: { id } });
// Update total count
const count = await prisma.smsContactListEntry.count({ where: { listId: entry.listId } });
await prisma.smsContactList.update({ where: { id: entry.listId }, data: { totalContacts: count } });
return entry;
},
/**
* Import contacts from CSV text into a list.
* Normalizes phone numbers, deduplicates by phone within the list.
*/
async importCsv(listId: string, csvText: string, filename?: string) {
const rows = parseCsv(csvText);
if (rows.length === 0) throw new Error('CSV file is empty or has no data rows');
const headers = Object.keys(rows[0]);
const phoneCol = detectPhoneColumn(headers);
if (!phoneCol) throw new Error('Could not detect phone column in CSV. Expected a column named "phone", "mobile", "cell", etc.');
const nameCol = detectNameColumn(headers);
const emailCol = detectEmailColumn(headers);
// Columns that aren't phone/name/email become custom fields
const customCols = headers.filter((h) => h !== phoneCol && h !== nameCol && h !== emailCol);
let imported = 0;
let skipped = 0;
let duplicates = 0;
for (const row of rows) {
const rawPhone = row[phoneCol];
if (!rawPhone) { skipped++; continue; }
const phone = normalizePhone(rawPhone);
if (!phone) { skipped++; continue; }
const customFields: Record<string, string> = {};
for (const col of customCols) {
if (row[col]) customFields[col] = row[col];
}
try {
await prisma.smsContactListEntry.upsert({
where: { listId_phone: { listId, phone } },
create: {
listId,
phone,
name: nameCol ? row[nameCol] || undefined : undefined,
email: emailCol ? row[emailCol] || undefined : undefined,
customFields: Object.keys(customFields).length > 0 ? customFields as unknown as Prisma.InputJsonValue : undefined,
},
update: {
name: nameCol ? row[nameCol] || undefined : undefined,
email: emailCol ? row[emailCol] || undefined : undefined,
customFields: Object.keys(customFields).length > 0 ? customFields as unknown as Prisma.InputJsonValue : undefined,
},
});
imported++;
} catch {
duplicates++;
}
}
// Update total count + filename
const count = await prisma.smsContactListEntry.count({ where: { listId } });
await prisma.smsContactList.update({
where: { id: listId },
data: {
totalContacts: count,
...(filename ? { originalFilename: filename } : {}),
},
});
return { imported, skipped, duplicates, total: count };
},
/**
* Import contacts from the phone's address book via Termux API
*/
async importFromPhone(listId: string) {
const contacts = await termuxClient.getContacts();
if (contacts.length === 0) throw new Error('No contacts returned from phone');
let imported = 0;
let skipped = 0;
for (const contact of contacts) {
if (!contact.number) { skipped++; continue; }
const phone = normalizePhone(contact.number);
if (!phone) { skipped++; continue; }
try {
await prisma.smsContactListEntry.upsert({
where: { listId_phone: { listId, phone } },
create: { listId, phone, name: contact.name || undefined },
update: { name: contact.name || undefined },
});
imported++;
} catch {
skipped++;
}
}
const count = await prisma.smsContactListEntry.count({ where: { listId } });
await prisma.smsContactList.update({ where: { id: listId }, data: { totalContacts: count } });
return { imported, skipped, total: count };
},
/**
* Find and count duplicate phone numbers within a list
*/
async deduplicateList(listId: string) {
// Since we have a unique constraint on [listId, phone], true duplicates
// shouldn't exist. This method counts entries and returns stats.
const count = await prisma.smsContactListEntry.count({ where: { listId } });
return { totalEntries: count, duplicatesRemoved: 0 };
},
};

View File

@@ -0,0 +1,83 @@
import { Router } from 'express';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { smsConversationsService } from './sms-conversations.service';
const router = Router();
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
// GET /api/sms/conversations — list conversations
router.get('/', async (req, res, next) => {
try {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 50));
const result = await smsConversationsService.findAll({
page,
limit,
search: req.query.search as string | undefined,
status: req.query.status as string | undefined,
campaignId: req.query.campaignId as string | undefined,
unreadOnly: req.query.unreadOnly === 'true',
});
res.json(result);
} catch (err) { next(err); }
});
// GET /api/sms/conversations/stats — conversation stats
router.get('/stats', async (_req, res, next) => {
try {
const stats = await smsConversationsService.getStats();
res.json(stats);
} catch (err) { next(err); }
});
// GET /api/sms/conversations/:id — single conversation with messages
router.get('/:id', async (req, res, next) => {
try {
const conversation = await smsConversationsService.findById(req.params.id as string);
if (!conversation) { res.status(404).json({ error: 'Conversation not found' }); return; }
res.json(conversation);
} catch (err) { next(err); }
});
// POST /api/sms/conversations/:id/read — mark conversation as read
router.post('/:id/read', async (req, res, next) => {
try {
await smsConversationsService.markRead(req.params.id as string);
res.json({ success: true });
} catch (err) { next(err); }
});
// PUT /api/sms/conversations/:id/notes — update notes
router.put('/:id/notes', async (req, res, next) => {
try {
const { notes } = req.body as { notes: string };
const conversation = await smsConversationsService.updateNotes(req.params.id as string, notes || '');
res.json(conversation);
} catch (err) { next(err); }
});
// PUT /api/sms/conversations/:id/tags — update tags
router.put('/:id/tags', async (req, res, next) => {
try {
const { tags } = req.body as { tags: string[] };
const conversation = await smsConversationsService.updateTags(req.params.id as string, tags || []);
res.json(conversation);
} catch (err) { next(err); }
});
// POST /api/sms/conversations/:id/reply — send reply
router.post('/:id/reply', async (req, res, next) => {
try {
const { message } = req.body as { message: string };
if (!message || typeof message !== 'string') {
res.status(400).json({ error: 'Message is required' });
return;
}
const smsMessage = await smsConversationsService.reply(req.params.id as string, message);
res.json(smsMessage);
} catch (err) { next(err); }
});
export const smsConversationsRouter = router;

View File

@@ -0,0 +1,139 @@
import { prisma } from '../../../config/database';
import { Prisma } from '@prisma/client';
import { smsQueueService } from '../../../services/sms-queue.service';
export const smsConversationsService = {
async findAll(options: {
page?: number;
limit?: number;
search?: string;
status?: string;
campaignId?: string;
unreadOnly?: boolean;
} = {}) {
const { page = 1, limit = 50, search, status, campaignId, unreadOnly } = options;
const skip = (page - 1) * limit;
const where: Prisma.SmsConversationWhereInput = {};
if (status) where.status = status as Prisma.EnumSmsConversationStatusFilter;
if (campaignId) where.campaignId = campaignId;
if (unreadOnly) where.unreadCount = { gt: 0 };
if (search) {
where.OR = [
{ phone: { contains: search } },
{ contactName: { contains: search, mode: 'insensitive' } },
];
}
const [items, total] = await Promise.all([
prisma.smsConversation.findMany({
where,
orderBy: { lastMessageAt: 'desc' },
skip,
take: limit,
include: {
campaign: { select: { id: true, name: true } },
},
}),
prisma.smsConversation.count({ where }),
]);
return { items, total, page, limit };
},
async findById(id: string) {
return prisma.smsConversation.findUnique({
where: { id },
include: {
campaign: { select: { id: true, name: true } },
messages: {
orderBy: { sentAt: 'asc' },
take: 200,
},
},
});
},
async markRead(id: string) {
// Mark conversation unread count to 0
await prisma.smsConversation.update({
where: { id },
data: { unreadCount: 0 },
});
// Mark all unread messages in this conversation as read
await prisma.smsMessage.updateMany({
where: { conversationId: id, isRead: false },
data: { isRead: true },
});
},
async updateNotes(id: string, notes: string) {
return prisma.smsConversation.update({
where: { id },
data: { notes },
});
},
async updateTags(id: string, tags: string[]) {
return prisma.smsConversation.update({
where: { id },
data: { tags: tags as unknown as Prisma.InputJsonValue },
});
},
/**
* Reply to a conversation (queues via BullMQ for rate limiting)
*/
async reply(id: string, message: string) {
const conversation = await prisma.smsConversation.findUnique({
where: { id },
select: { phone: true, campaignId: true, status: true },
});
if (!conversation) throw new Error('Conversation not found');
if (conversation.status === 'OPTED_OUT') throw new Error('Cannot reply to opted-out conversation');
// Create outbound message
const smsMessage = await prisma.smsMessage.create({
data: {
phone: conversation.phone,
message,
direction: 'OUTBOUND',
status: 'PENDING',
connectionType: 'termux',
campaignId: conversation.campaignId,
conversationId: id,
},
});
// Queue the SMS send
await smsQueueService.addSmsJob({
recipientId: smsMessage.id, // Use message ID as recipient ID for ad-hoc replies
campaignId: conversation.campaignId || '',
phone: conversation.phone,
message,
attemptNumber: 1,
});
// Update conversation stats
await prisma.smsConversation.update({
where: { id },
data: {
totalMessages: { increment: 1 },
lastMessageAt: new Date(),
},
});
return smsMessage;
},
async getStats() {
const [total, active, optedOut, unread] = await Promise.all([
prisma.smsConversation.count(),
prisma.smsConversation.count({ where: { status: 'ACTIVE' } }),
prisma.smsConversation.count({ where: { status: 'OPTED_OUT' } }),
prisma.smsConversation.count({ where: { unreadCount: { gt: 0 } } }),
]);
return { total, active, optedOut, unread };
},
};

View File

@@ -0,0 +1,43 @@
import { Router } from 'express';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { smsDeviceService } from './sms-device.service';
const router = Router();
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
// GET /api/sms/device — latest device status
router.get('/', async (_req, res, next) => {
try {
const status = await smsDeviceService.getStatus();
res.json(status);
} catch (err) { next(err); }
});
// GET /api/sms/device/history — device status history
router.get('/history', async (req, res, next) => {
try {
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 50));
const history = await smsDeviceService.getHistory(limit);
res.json(history);
} catch (err) { next(err); }
});
// GET /api/sms/device/live — live device info directly from Termux
router.get('/live', async (_req, res, next) => {
try {
const info = await smsDeviceService.getLiveInfo();
res.json(info);
} catch (err) { next(err); }
});
// POST /api/sms/device/sync — trigger immediate response sync
router.post('/sync', async (_req, res, next) => {
try {
const result = await smsDeviceService.triggerSync();
res.json(result);
} catch (err) { next(err); }
});
export const smsDeviceRouter = router;

View File

@@ -0,0 +1,55 @@
import { prisma } from '../../../config/database';
import { termuxClient } from '../../../services/termux.client';
import { smsResponseSyncService } from '../../../services/sms-response-sync.service';
export const smsDeviceService = {
/**
* Get the latest device status from the database
*/
async getStatus() {
const latest = await prisma.smsDeviceStatus.findFirst({
orderBy: { lastCheckedAt: 'desc' },
});
return latest || {
isConnected: false,
connectionType: null,
batteryLevel: null,
batteryStatus: null,
totalSent: 0,
lastCheckedAt: null,
};
},
/**
* Get device status history (last N entries)
*/
async getHistory(limit = 50) {
return prisma.smsDeviceStatus.findMany({
orderBy: { lastCheckedAt: 'desc' },
take: limit,
});
},
/**
* Get live device info directly from Termux
*/
async getLiveInfo() {
const [isAvailable, health, battery, deviceInfo] = await Promise.all([
termuxClient.isAvailable(),
termuxClient.getHealth(),
termuxClient.getBattery(),
termuxClient.getDeviceInfo(),
]);
return { isAvailable, health, battery, deviceInfo };
},
/**
* Trigger an immediate response sync
*/
async triggerSync() {
await smsResponseSyncService.sync();
return { success: true };
},
};

View File

@@ -0,0 +1,46 @@
import { Router } from 'express';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { smsMessagesService } from './sms-messages.service';
const router = Router();
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
// GET /api/sms/messages — list all messages
router.get('/', async (req, res, next) => {
try {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 50));
const result = await smsMessagesService.findAll({
page,
limit,
direction: req.query.direction as string | undefined,
phone: req.query.phone as string | undefined,
});
res.json(result);
} catch (err) { next(err); }
});
// GET /api/sms/messages/followups — messages needing follow-up
router.get('/followups', async (_req, res, next) => {
try {
const messages = await smsMessagesService.getFollowups();
res.json(messages);
} catch (err) { next(err); }
});
// POST /api/sms/messages/send — send ad-hoc SMS
router.post('/send', async (req, res, next) => {
try {
const { phone, message } = req.body as { phone?: string; message?: string };
if (!phone || !message) {
res.status(400).json({ error: 'Phone and message are required' });
return;
}
const result = await smsMessagesService.sendSingle(phone, message);
res.json(result);
} catch (err) { next(err); }
});
export const smsMessagesRouter = router;

View File

@@ -0,0 +1,70 @@
import { prisma } from '../../../config/database';
import { termuxClient } from '../../../services/termux.client';
export const smsMessagesService = {
/**
* Send a single ad-hoc SMS (not part of a campaign)
*/
async sendSingle(phone: string, message: string) {
const result = await termuxClient.sendSms(phone, message);
const smsMessage = await prisma.smsMessage.create({
data: {
phone,
message,
direction: 'OUTBOUND',
status: result.success ? 'SENT' : 'FAILED',
connectionType: 'termux',
sentAt: new Date(),
},
});
return { ...smsMessage, termuxResult: result };
},
/**
* Get recent messages (paginated, for overview)
*/
async findAll(options: { page?: number; limit?: number; direction?: string; phone?: string } = {}) {
const { page = 1, limit = 50, direction, phone } = options;
const skip = (page - 1) * limit;
const where: Record<string, unknown> = {};
if (direction) where.direction = direction;
if (phone) where.phone = { contains: phone };
const [items, total] = await Promise.all([
prisma.smsMessage.findMany({
where,
orderBy: { sentAt: 'desc' },
skip,
take: limit,
include: {
campaign: { select: { id: true, name: true } },
conversation: { select: { id: true, contactName: true } },
},
}),
prisma.smsMessage.count({ where }),
]);
return { items, total, page, limit };
},
/**
* Get messages that need follow-up (inbound, unread, no reply)
*/
async getFollowups(limit = 50) {
return prisma.smsMessage.findMany({
where: {
direction: 'INBOUND',
isRead: false,
responseType: { in: ['QUESTION', 'POSITIVE'] },
},
orderBy: { sentAt: 'desc' },
take: limit,
include: {
conversation: { select: { id: true, contactName: true, phone: true } },
},
});
},
};

View File

@@ -0,0 +1,219 @@
import { Router } from 'express';
import crypto from 'crypto';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { siteSettingsService } from '../../settings/settings.service';
import { termuxClient, TermuxClient } from '../../../services/termux.client';
import { tailscaleClient, TailscaleClient } from '../../../services/tailscale.client';
import { logger } from '../../../utils/logger';
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redis } from '../../../config/redis';
const router = Router();
// Rate limit: 10 per 5 minutes (same as Pangolin setup)
const setupRateLimit = rateLimit({
windowMs: 5 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:sms-setup:',
}),
message: {
error: {
message: 'Too many SMS setup requests, please try again later',
code: 'SMS_SETUP_RATE_LIMIT_EXCEEDED',
},
},
});
// All routes require SUPER_ADMIN (secrets management)
router.use(authenticate, requireRole('SUPER_ADMIN'), setupRateLimit);
/**
* GET /api/sms/setup/status
* Quick status check — is SMS configured and connected?
*/
router.get('/status', async (_req, res) => {
try {
const connected = termuxClient.enabled ? await termuxClient.isAvailable() : false;
res.json({
configured: termuxClient.enabled,
connected,
tailscaleConfigured: tailscaleClient.configured,
source: termuxClient.configSource,
});
} catch (err) {
logger.warn('SMS setup status check failed:', err);
res.status(500).json({ error: { message: 'Failed to check SMS status', code: 'INTERNAL_ERROR' } });
}
});
/**
* GET /api/sms/setup/config
* Return current SMS config (admin-only).
* Masks API key values for display.
*/
router.get('/config', async (_req, res) => {
try {
const settings = await siteSettingsService.get();
res.json({
enableSms: settings.enableSms,
smsTermuxApiUrl: settings.smsTermuxApiUrl || '',
smsTermuxApiKey: settings.smsTermuxApiKey ? '••••••••' : '',
smsTermuxApiKeySet: !!settings.smsTermuxApiKey,
smsTailscaleApiKey: settings.smsTailscaleApiKey ? '••••••••' : '',
smsTailscaleApiKeySet: !!settings.smsTailscaleApiKey,
smsTailscaleTailnet: settings.smsTailscaleTailnet || '',
smsTailscaleDeviceId: settings.smsTailscaleDeviceId || '',
smsTailscaleDeviceName: settings.smsTailscaleDeviceName || '',
});
} catch (err) {
logger.warn('SMS setup config fetch failed:', err);
res.status(500).json({ error: { message: 'Failed to fetch SMS config', code: 'INTERNAL_ERROR' } });
}
});
/**
* POST /api/sms/setup/tailscale/devices
* Query the Tailscale API for devices. Temporarily configures the client.
* Body: { apiKey: string, tailnet?: string }
*/
router.post('/tailscale/devices', async (req, res) => {
try {
const { apiKey, tailnet } = req.body as { apiKey?: string; tailnet?: string };
if (!apiKey) {
res.status(400).json({ error: { message: 'apiKey is required', code: 'VALIDATION_ERROR' } });
return;
}
// Use a throwaway instance to avoid mutating the shared singleton
const tmp = new TailscaleClient();
tmp.configure(apiKey, tailnet || undefined);
const devices = await tmp.listDevices();
// Sort: Android first, then by online status, then by name
const sorted = [...devices].sort((a, b) => {
const aAndroid = a.os.toLowerCase() === 'android' ? 0 : 1;
const bAndroid = b.os.toLowerCase() === 'android' ? 0 : 1;
if (aAndroid !== bAndroid) return aAndroid - bAndroid;
if (a.online !== b.online) return a.online ? -1 : 1;
return a.name.localeCompare(b.name);
});
res.json({ devices: sorted });
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to query Tailscale API';
logger.warn('Tailscale device discovery failed:', msg);
res.status(502).json({ error: { message: msg, code: 'TAILSCALE_ERROR' } });
}
});
/**
* POST /api/sms/setup/test-connection
* Test Termux connectivity with arbitrary URL + key (does not use stored config).
* Body: { url: string, apiKey: string }
*/
router.post('/test-connection', async (req, res) => {
try {
const { url, apiKey } = req.body as { url?: string; apiKey?: string };
if (!url || !apiKey) {
res.status(400).json({ error: { message: 'url and apiKey are required', code: 'VALIDATION_ERROR' } });
return;
}
// Validate URL format
try {
new URL(url);
} catch {
res.status(400).json({ error: { message: 'Invalid URL format', code: 'VALIDATION_ERROR' } });
return;
}
const result = await TermuxClient.testConnection(url, apiKey);
res.json(result);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Connection test failed';
res.status(500).json({ error: { message: msg, code: 'INTERNAL_ERROR' } });
}
});
/**
* POST /api/sms/setup/save-config
* Save all SMS config to DB, then reload termux client.
*/
router.post('/save-config', async (req, res) => {
try {
const {
enableSms,
smsTermuxApiUrl,
smsTermuxApiKey,
smsTailscaleApiKey,
smsTailscaleTailnet,
smsTailscaleDeviceId,
smsTailscaleDeviceName,
} = req.body as Record<string, unknown>;
// Build update payload (only include fields that were provided)
const update: Record<string, unknown> = {};
if (typeof enableSms === 'boolean') update.enableSms = enableSms;
if (typeof smsTermuxApiUrl === 'string') update.smsTermuxApiUrl = smsTermuxApiUrl;
if (typeof smsTermuxApiKey === 'string') update.smsTermuxApiKey = smsTermuxApiKey;
if (typeof smsTailscaleApiKey === 'string') update.smsTailscaleApiKey = smsTailscaleApiKey;
if (typeof smsTailscaleTailnet === 'string') update.smsTailscaleTailnet = smsTailscaleTailnet;
if (typeof smsTailscaleDeviceId === 'string') update.smsTailscaleDeviceId = smsTailscaleDeviceId;
if (typeof smsTailscaleDeviceName === 'string') update.smsTailscaleDeviceName = smsTailscaleDeviceName;
// Validate URL format if provided
if (typeof smsTermuxApiUrl === 'string' && smsTermuxApiUrl) {
try {
new URL(smsTermuxApiUrl);
} catch {
res.status(400).json({ error: { message: 'Invalid Termux API URL format', code: 'VALIDATION_ERROR' } });
return;
}
}
if (Object.keys(update).length === 0) {
res.status(400).json({ error: { message: 'No fields to update', code: 'VALIDATION_ERROR' } });
return;
}
await siteSettingsService.update(update);
// Hot-reload the termux client with new config
await termuxClient.configureFromDb();
// Also reload tailscale client if tailscale key was provided
if (typeof smsTailscaleApiKey === 'string' || typeof smsTailscaleTailnet === 'string') {
await tailscaleClient.configureFromDb();
}
logger.info(`SMS config updated by ${req.user?.email}`);
res.json({ success: true, message: 'SMS configuration saved' });
} catch (err) {
logger.error('SMS config save failed:', err);
res.status(500).json({ error: { message: 'Failed to save SMS configuration', code: 'INTERNAL_ERROR' } });
}
});
/**
* POST /api/sms/setup/generate-key
* Generate a 64-character hex API key for Termux server auth.
*/
router.post('/generate-key', (_req, res) => {
const key = crypto.randomBytes(32).toString('hex');
res.json({ key });
});
export const smsSetupRouter = router;

View File

@@ -0,0 +1,99 @@
import { Router } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { checkSocialEnabled } from './social.middleware';
import { achievementsService } from './achievements.service';
const router = Router();
router.use(authenticate, checkSocialEnabled);
/** GET /api/social/achievements — my achievements with progress */
router.get('/', async (req, res, next) => {
try {
const achievements = await achievementsService.listForUser(req.user!.id);
res.json({ achievements });
} catch (err) {
next(err);
}
});
/** GET /api/social/achievements/definitions — all achievement definitions */
router.get('/definitions', async (_req, res) => {
res.json({ achievements: achievementsService.getDefinitions() });
});
/** GET /api/social/achievements/stats — my volunteer stats */
router.get('/stats', async (req, res, next) => {
try {
const stats = await achievementsService.getVolunteerStats(req.user!.id);
res.json({ stats });
} catch (err) {
next(err);
}
});
/** GET /api/social/achievements/stats/:userId — another user's stats (privacy-filtered) */
router.get('/stats/:userId', async (req, res, next) => {
try {
const targetUserId = req.params.userId as string;
// Privacy check
const privacy = await (await import('../../config/database')).prisma.privacySettings.findUnique({
where: { userId: targetUserId },
});
if (privacy?.showInFriendActivity === false && targetUserId !== req.user!.id) {
return res.json({ stats: null });
}
const stats = await achievementsService.getVolunteerStats(targetUserId);
res.json({ stats });
} catch (err) {
next(err);
}
});
/** GET /api/social/achievements/user/:userId — another user's achievements */
router.get('/user/:userId', async (req, res, next) => {
try {
const targetUserId = req.params.userId as string;
// Privacy check
const privacy = await (await import('../../config/database')).prisma.privacySettings.findUnique({
where: { userId: targetUserId },
});
if (privacy?.showInFriendActivity === false && targetUserId !== req.user!.id) {
return res.json({ achievements: [] });
}
const achievements = await achievementsService.listForUser(targetUserId);
// Only return unlocked achievements for other users
const filtered = targetUserId === req.user!.id
? achievements
: achievements.filter((a) => a.unlocked);
res.json({ achievements: filtered });
} catch (err) {
next(err);
}
});
/** GET /api/social/achievements/leaderboard — leaderboard */
router.get('/leaderboard', async (req, res, next) => {
try {
const type = (req.query.type as string) || 'canvass';
if (!['canvass', 'shifts', 'campaigns'].includes(type)) {
return res.status(400).json({ error: { message: 'Invalid leaderboard type' } });
}
const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
const leaderboard = await achievementsService.getLeaderboard(type as 'canvass' | 'shifts' | 'campaigns', limit);
const myRank = await achievementsService.getUserRank(req.user!.id, type as 'canvass' | 'shifts' | 'campaigns');
res.json({ leaderboard, myRank, type });
} catch (err) {
next(err);
}
});
export const achievementsRouter = router;

View File

@@ -0,0 +1,391 @@
import { prisma } from '../../config/database';
import { SignupStatus } from '@prisma/client';
import { notificationService } from './notification.service';
import { logger } from '../../utils/logger';
/** Achievement definition */
interface AchievementDef {
id: string;
name: string;
description: string;
icon: string;
category: 'shifts' | 'canvass' | 'campaigns' | 'social';
threshold: number;
/** Function to compute current progress for a user */
getProgress: (userId: string) => Promise<number>;
}
/** Static achievement registry */
const ACHIEVEMENTS: AchievementDef[] = [
// Shift achievements
{
id: 'FIRST_SHIFT',
name: 'First Steps',
description: 'Sign up for your first volunteer shift',
icon: 'calendar',
category: 'shifts',
threshold: 1,
getProgress: async (userId) => {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
if (!user) return 0;
return prisma.shiftSignup.count({
where: { userEmail: user.email, status: SignupStatus.CONFIRMED },
});
},
},
{
id: 'SHIFT_STREAK_3',
name: 'Reliable Volunteer',
description: 'Sign up for 3 volunteer shifts',
icon: 'calendar',
category: 'shifts',
threshold: 3,
getProgress: async (userId) => {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
if (!user) return 0;
return prisma.shiftSignup.count({
where: { userEmail: user.email, status: SignupStatus.CONFIRMED },
});
},
},
{
id: 'SHIFT_STREAK_10',
name: 'Shift Champion',
description: 'Sign up for 10 volunteer shifts',
icon: 'trophy',
category: 'shifts',
threshold: 10,
getProgress: async (userId) => {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
if (!user) return 0;
return prisma.shiftSignup.count({
where: { userEmail: user.email, status: SignupStatus.CONFIRMED },
});
},
},
// Canvass achievements
{
id: 'FIRST_CANVASS',
name: 'Door Knocker',
description: 'Complete your first canvass session',
icon: 'environment',
category: 'canvass',
threshold: 1,
getProgress: async (userId) =>
prisma.canvassSession.count({
where: { userId, status: 'COMPLETED' },
}),
},
{
id: 'CANVASS_50_DOORS',
name: 'Neighbourhood Explorer',
description: 'Record 50 canvass visits',
icon: 'home',
category: 'canvass',
threshold: 50,
getProgress: async (userId) =>
prisma.canvassVisit.count({
where: { session: { userId } },
}),
},
{
id: 'CANVASS_100_DOORS',
name: 'Community Connector',
description: 'Record 100 canvass visits',
icon: 'home',
category: 'canvass',
threshold: 100,
getProgress: async (userId) =>
prisma.canvassVisit.count({
where: { session: { userId } },
}),
},
{
id: 'CANVASS_500_DOORS',
name: 'Door-to-Door Legend',
description: 'Record 500 canvass visits',
icon: 'star',
category: 'canvass',
threshold: 500,
getProgress: async (userId) =>
prisma.canvassVisit.count({
where: { session: { userId } },
}),
},
// Campaign achievements
{
id: 'FIRST_CAMPAIGN_EMAIL',
name: 'Voice Heard',
description: 'Send your first advocacy email',
icon: 'mail',
category: 'campaigns',
threshold: 1,
getProgress: async (userId) =>
prisma.campaignEmail.count({
where: { userId },
}),
},
{
id: 'CAMPAIGN_CHAMPION',
name: 'Campaign Champion',
description: 'Participate in 5 different campaigns',
icon: 'fire',
category: 'campaigns',
threshold: 5,
getProgress: async (userId) => {
const result = await prisma.campaignEmail.findMany({
where: { userId },
distinct: ['campaignId'],
select: { campaignId: true },
});
return result.length;
},
},
// Social achievements
{
id: 'SOCIAL_BUTTERFLY',
name: 'Social Butterfly',
description: 'Make 10 friends on the platform',
icon: 'team',
category: 'social',
threshold: 10,
getProgress: async (userId) =>
prisma.friendship.count({
where: {
status: 'accepted',
OR: [{ userId }, { friendId: userId }],
},
}),
},
{
id: 'TEAM_PLAYER',
name: 'Team Player',
description: 'Be a member of 3 groups',
icon: 'usergroup-add',
category: 'social',
threshold: 3,
getProgress: async (userId) =>
prisma.socialGroupMember.count({
where: { userId },
}),
},
];
/** Achievement map for quick lookup */
const ACHIEVEMENT_MAP = new Map(ACHIEVEMENTS.map((a) => [a.id, a]));
export const achievementsService = {
/** Get all achievement definitions */
getDefinitions() {
return ACHIEVEMENTS.map(({ getProgress, ...rest }) => rest);
},
/** Get a user's achievements with progress */
async listForUser(userId: string) {
const unlocked = await prisma.userAchievement.findMany({
where: { userId },
});
const unlockedMap = new Map(unlocked.map((u) => [u.achievementId, u]));
const results = await Promise.all(
ACHIEVEMENTS.map(async ({ getProgress, ...def }) => {
const record = unlockedMap.get(def.id);
let progress: number;
if (record) {
// Already unlocked — use stored progress (at least threshold)
progress = Math.max(record.progress ?? def.threshold, def.threshold);
} else {
// Compute current progress
progress = await getProgress(userId);
}
return {
...def,
progress,
unlocked: !!record,
unlockedAt: record?.unlockedAt ?? null,
};
}),
);
return results;
},
/** Check and unlock achievements for a user after a specific event */
async checkAndUnlock(userId: string, categories?: string[]) {
const toCheck = categories
? ACHIEVEMENTS.filter((a) => categories.includes(a.category))
: ACHIEVEMENTS;
for (const achievement of toCheck) {
try {
// Skip if already unlocked
const existing = await prisma.userAchievement.findUnique({
where: { userId_achievementId: { userId, achievementId: achievement.id } },
});
if (existing) continue;
const progress = await achievement.getProgress(userId);
if (progress >= achievement.threshold) {
await prisma.userAchievement.create({
data: {
userId,
achievementId: achievement.id,
unlockedAt: new Date(),
progress,
notified: false,
},
});
// Create in-app notification
await notificationService.createNotification(
userId,
'achievement',
'Achievement Unlocked!',
`You earned "${achievement.name}" — ${achievement.description}`,
{ achievementId: achievement.id, icon: achievement.icon },
);
logger.info(`Achievement unlocked: ${achievement.id} for user ${userId}`);
}
} catch (err) {
logger.warn(`Failed to check achievement ${achievement.id} for ${userId}:`, err);
}
}
},
/** Get leaderboard by metric */
async getLeaderboard(type: 'canvass' | 'shifts' | 'campaigns', limit = 10) {
if (type === 'canvass') {
// Rank by total canvass visits
const results = await prisma.$queryRaw<{ userId: string; count: bigint }[]>`
SELECT cs."userId", COUNT(cv.id) AS count
FROM canvass_sessions cs
JOIN canvass_visits cv ON cv."sessionId" = cs.id
WHERE cs."userId" IS NOT NULL
GROUP BY cs."userId"
ORDER BY count DESC
LIMIT ${limit}
`;
return this.hydrateLeaderboard(results.map((r) => ({
userId: r.userId,
score: Number(r.count),
})));
}
if (type === 'shifts') {
// Rank by total confirmed shift signups
const results = await prisma.$queryRaw<{ userId: string; count: bigint }[]>`
SELECT ss."userId", COUNT(ss.id) AS count
FROM shift_signups ss
WHERE ss."userId" IS NOT NULL AND ss.status = 'CONFIRMED'
GROUP BY ss."userId"
ORDER BY count DESC
LIMIT ${limit}
`;
return this.hydrateLeaderboard(results.map((r) => ({
userId: r.userId,
score: Number(r.count),
})));
}
// campaigns — rank by distinct campaigns participated
const results = await prisma.$queryRaw<{ userId: string; count: bigint }[]>`
SELECT ce."userId", COUNT(DISTINCT ce."campaignId") AS count
FROM campaign_emails ce
WHERE ce."userId" IS NOT NULL
GROUP BY ce."userId"
ORDER BY count DESC
LIMIT ${limit}
`;
return this.hydrateLeaderboard(results.map((r) => ({
userId: r.userId,
score: Number(r.count),
})));
},
/** Hydrate leaderboard entries with user data */
async hydrateLeaderboard(entries: { userId: string; score: number }[]) {
if (entries.length === 0) return [];
// Filter out users who hide from activity feeds
const hiddenIds = new Set(
(await prisma.privacySettings.findMany({
where: {
userId: { in: entries.map((e) => e.userId) },
showInFriendActivity: false,
},
select: { userId: true },
})).map((p) => p.userId),
);
const visibleEntries = entries.filter((e) => !hiddenIds.has(e.userId));
const users = await prisma.user.findMany({
where: { id: { in: visibleEntries.map((e) => e.userId) } },
select: { id: true, name: true, email: true },
});
const userMap = new Map(users.map((u) => [u.id, u]));
return visibleEntries.map((e, i) => ({
rank: i + 1,
userId: e.userId,
name: userMap.get(e.userId)?.name || null,
email: userMap.get(e.userId)?.email || '',
score: e.score,
}));
},
/** Get a user's rank in a specific leaderboard */
async getUserRank(userId: string, type: 'canvass' | 'shifts' | 'campaigns') {
const leaderboard = await this.getLeaderboard(type, 1000);
const idx = leaderboard.findIndex((e) => e.userId === userId);
return idx >= 0 ? idx + 1 : null;
},
/** Get volunteer stats for a user (computed on-the-fly) */
async getVolunteerStats(userId: string) {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
if (!user) return null;
const [shiftSignups, canvassSessions, canvassVisits, campaignEmails, distinctCampaigns, friendCount, groupCount] = await Promise.all([
prisma.shiftSignup.count({
where: { userEmail: user.email, status: SignupStatus.CONFIRMED },
}),
prisma.canvassSession.count({
where: { userId, status: 'COMPLETED' },
}),
prisma.canvassVisit.count({
where: { session: { userId } },
}),
prisma.campaignEmail.count({
where: { userId },
}),
prisma.campaignEmail.findMany({
where: { userId },
distinct: ['campaignId'],
select: { campaignId: true },
}),
prisma.friendship.count({
where: { status: 'accepted', OR: [{ userId }, { friendId: userId }] },
}),
prisma.socialGroupMember.count({
where: { userId },
}),
]);
return {
shiftSignups,
canvassSessions,
canvassVisits,
campaignEmails,
campaignsParticipated: distinctCampaigns.length,
friendCount,
groupCount,
};
},
};

View File

@@ -0,0 +1,49 @@
import { Router } from 'express';
import type { Request, Response } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { socialActionRateLimit } from './social.rate-limits';
import { blockService } from './block.service';
import { checkSocialEnabled } from './social.middleware';
import { z } from 'zod';
const blockUserSchema = z.object({
userId: z.string().cuid(),
});
const router = Router();
router.use(authenticate);
router.use(checkSocialEnabled);
/** POST /api/social/blocks — block a user */
router.post('/', socialActionRateLimit, async (req: Request, res: Response) => {
try {
const { userId } = blockUserSchema.parse(req.body);
const result = await blockService.blockUser(req.user!.id, userId);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'BLOCK_ERROR' } });
}
});
/** DELETE /api/social/blocks/:userId — unblock a user */
router.delete('/:userId', async (req: Request, res: Response) => {
try {
const blockedUserId = req.params.userId as string;
const result = await blockService.unblockUser(req.user!.id, blockedUserId);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'UNBLOCK_ERROR' } });
}
});
/** GET /api/social/blocks — list blocked users */
router.get('/', async (req: Request, res: Response) => {
try {
const blocked = await blockService.listBlocked(req.user!.id);
res.json({ blocked });
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'BLOCKS_LIST_ERROR' } });
}
});
export { router as blockRouter };

View File

@@ -0,0 +1,97 @@
import { prisma } from '../../config/database';
const BLOCKED_USER_SELECT = {
id: true,
email: true,
name: true,
} as const;
export const blockService = {
/** Block a user. Auto-unfriends + removes from close friends. */
async blockUser(userId: string, blockedUserId: string) {
if (userId === blockedUserId) {
throw Object.assign(new Error('Cannot block yourself'), { statusCode: 400 });
}
// Check target exists
const target = await prisma.user.findUnique({ where: { id: blockedUserId }, select: { id: true } });
if (!target) {
throw Object.assign(new Error('User not found'), { statusCode: 404 });
}
// Check not already blocked
const existing = await prisma.userBlock.findFirst({
where: { userId, blockedUserId },
});
if (existing) {
throw Object.assign(new Error('User already blocked'), { statusCode: 409 });
}
// Block + auto-unfriend + remove close friends in a transaction
await prisma.$transaction([
prisma.userBlock.create({ data: { userId, blockedUserId } }),
// Remove any friendship between the two (either direction)
prisma.friendship.deleteMany({
where: {
OR: [
{ userId, friendId: blockedUserId },
{ userId: blockedUserId, friendId: userId },
],
},
}),
// Remove from close friends (either direction)
prisma.closeFriend.deleteMany({
where: {
OR: [
{ userId, closeFriendId: blockedUserId },
{ userId: blockedUserId, closeFriendId: userId },
],
},
}),
]);
return { success: true };
},
/** Unblock a user */
async unblockUser(userId: string, blockedUserId: string) {
const block = await prisma.userBlock.findFirst({
where: { userId, blockedUserId },
});
if (!block) {
throw Object.assign(new Error('User is not blocked'), { statusCode: 404 });
}
await prisma.userBlock.delete({ where: { id: block.id } });
return { success: true };
},
/** List users blocked by this user */
async listBlocked(userId: string) {
const blocks = await prisma.userBlock.findMany({
where: { userId },
include: { blockedUser: { select: BLOCKED_USER_SELECT } },
orderBy: { createdAt: 'desc' },
});
return blocks.map((b) => ({
blockId: b.id,
blockedAt: b.createdAt,
user: b.blockedUser,
}));
},
/** Check if either user has blocked the other */
async isBlocked(userId: string, otherUserId: string): Promise<boolean> {
const block = await prisma.userBlock.findFirst({
where: {
OR: [
{ userId, blockedUserId: otherUserId },
{ userId: otherUserId, blockedUserId: userId },
],
},
});
return !!block;
},
};

View File

@@ -0,0 +1,34 @@
import { Router } from 'express';
import type { Request, Response } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { feedService } from './feed.service';
import { checkSocialEnabled } from './social.middleware';
import { friendsPaginationSchema } from './social.schemas';
const router = Router();
router.use(authenticate);
router.use(checkSocialEnabled);
/** GET /api/social/feed — paginated friends activity feed */
router.get('/', async (req: Request, res: Response) => {
try {
const { page, limit } = friendsPaginationSchema.parse(req.query);
const result = await feedService.getFriendFeed(req.user!.id, page, limit);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'FEED_ERROR' } });
}
});
/** GET /api/social/feed/my — own activity */
router.get('/my', async (req: Request, res: Response) => {
try {
const { page, limit } = friendsPaginationSchema.parse(req.query);
const result = await feedService.getMyActivity(req.user!.id, page, limit);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'MY_ACTIVITY_ERROR' } });
}
});
export { router as feedRouter };

View File

@@ -0,0 +1,238 @@
import { prisma } from '../../config/database';
import { redis } from '../../config/redis';
import { friendshipService } from './friendship.service';
/** A unified feed item representing any activity type */
export interface FeedItem {
id: string;
type: 'shift_signup' | 'campaign_email' | 'canvass_session' | 'response_submitted';
userId: string;
userName: string | null;
userEmail: string;
title: string;
description: string;
metadata: Record<string, unknown>;
timestamp: Date;
}
const FEED_CACHE_TTL = 120; // 2 minutes
const FEED_MAX_AGE_DAYS = 30;
const FEED_MAX_ITEMS = 50;
export const feedService = {
/**
* Get a combined feed of friends' recent activities.
* Aggregates shift signups, campaign emails, canvass sessions, and response submissions.
* Results are cached in Redis for 2 minutes.
*/
async getFriendFeed(userId: string, page: number, limit: number) {
const cacheKey = `social:feed:${userId}:${page}:${limit}`;
// Check cache
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Get friend IDs + their privacy settings
const friendIds = await friendshipService.getFriendIds(userId);
if (friendIds.length === 0) {
return { items: [], pagination: { page, limit, total: 0, totalPages: 0 } };
}
// Filter out friends who have showInFriendActivity disabled
const privacySettings = await prisma.privacySettings.findMany({
where: { userId: { in: friendIds }, showInFriendActivity: false },
select: { userId: true },
});
const hiddenIds = new Set(privacySettings.map((p) => p.userId));
const visibleFriendIds = friendIds.filter((id) => !hiddenIds.has(id));
if (visibleFriendIds.length === 0) {
return { items: [], pagination: { page, limit, total: 0, totalPages: 0 } };
}
const since = new Date();
since.setDate(since.getDate() - FEED_MAX_AGE_DAYS);
// Query all activity types in parallel
const [shiftSignups, campaignEmails, canvassSessions, responses] = await Promise.all([
this.getShiftSignupActivities(visibleFriendIds, since),
this.getCampaignEmailActivities(visibleFriendIds, since),
this.getCanvassSessionActivities(visibleFriendIds, since),
this.getResponseActivities(visibleFriendIds, since),
]);
// Merge and sort by timestamp descending
const allItems: FeedItem[] = [
...shiftSignups,
...campaignEmails,
...canvassSessions,
...responses,
].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
// Cap total items
const cappedItems = allItems.slice(0, FEED_MAX_ITEMS);
const total = cappedItems.length;
const totalPages = Math.ceil(total / limit);
const skip = (page - 1) * limit;
const items = cappedItems.slice(skip, skip + limit);
const result = {
items,
pagination: { page, limit, total, totalPages },
};
// Cache for 2 minutes
await redis.setex(cacheKey, FEED_CACHE_TTL, JSON.stringify(result));
return result;
},
/** Get own activity for profile display */
async getMyActivity(userId: string, page: number, limit: number) {
const since = new Date();
since.setDate(since.getDate() - FEED_MAX_AGE_DAYS);
const [shiftSignups, campaignEmails, canvassSessions, responses] = await Promise.all([
this.getShiftSignupActivities([userId], since),
this.getCampaignEmailActivities([userId], since),
this.getCanvassSessionActivities([userId], since),
this.getResponseActivities([userId], since),
]);
const allItems: FeedItem[] = [
...shiftSignups,
...campaignEmails,
...canvassSessions,
...responses,
].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
const total = allItems.length;
const totalPages = Math.ceil(total / limit);
const skip = (page - 1) * limit;
const items = allItems.slice(skip, skip + limit);
return { items, pagination: { page, limit, total, totalPages } };
},
// --- Activity type queries ---
async getShiftSignupActivities(userIds: string[], since: Date): Promise<FeedItem[]> {
const signups = await prisma.shiftSignup.findMany({
where: {
userId: { in: userIds },
signupDate: { gte: since },
status: 'CONFIRMED',
},
include: {
user: { select: { id: true, name: true, email: true } },
shift: { select: { id: true, title: true, startTime: true } },
},
orderBy: { signupDate: 'desc' },
take: FEED_MAX_ITEMS,
});
return signups
.filter((s) => s.user)
.map((s) => ({
id: `shift_signup:${s.id}`,
type: 'shift_signup' as const,
userId: s.user!.id,
userName: s.user!.name,
userEmail: s.user!.email,
title: 'Signed up for a shift',
description: s.shift.title,
metadata: { shiftId: s.shift.id, shiftTitle: s.shift.title, startTime: s.shift.startTime },
timestamp: s.signupDate,
}));
},
async getCampaignEmailActivities(userIds: string[], since: Date): Promise<FeedItem[]> {
const emails = await prisma.campaignEmail.findMany({
where: {
userId: { in: userIds },
sentAt: { gte: since },
},
include: {
user: { select: { id: true, name: true, email: true } },
campaign: { select: { id: true, title: true, slug: true } },
},
orderBy: { sentAt: 'desc' },
take: FEED_MAX_ITEMS,
});
return emails
.filter((e) => e.user)
.map((e) => ({
id: `campaign_email:${e.id}`,
type: 'campaign_email' as const,
userId: e.user!.id,
userName: e.user!.name,
userEmail: e.user!.email,
title: 'Participated in a campaign',
description: e.campaign.title,
metadata: { campaignId: e.campaign.id, campaignSlug: e.campaign.slug },
timestamp: e.sentAt!,
}));
},
async getCanvassSessionActivities(userIds: string[], since: Date): Promise<FeedItem[]> {
const sessions = await prisma.canvassSession.findMany({
where: {
userId: { in: userIds },
startedAt: { gte: since },
status: 'COMPLETED',
},
include: {
user: { select: { id: true, name: true, email: true } },
cut: { select: { id: true, name: true } },
_count: { select: { visits: true } },
},
orderBy: { startedAt: 'desc' },
take: FEED_MAX_ITEMS,
});
return sessions.map((s) => ({
id: `canvass_session:${s.id}`,
type: 'canvass_session' as const,
userId: s.user.id,
userName: s.user.name,
userEmail: s.user.email,
title: 'Completed a canvass session',
description: `${s._count.visits} doors in ${s.cut.name}`,
metadata: { cutId: s.cut.id, cutName: s.cut.name, visitCount: s._count.visits },
timestamp: s.startedAt,
}));
},
async getResponseActivities(userIds: string[], since: Date): Promise<FeedItem[]> {
const responses = await prisma.representativeResponse.findMany({
where: {
submittedByUserId: { in: userIds },
createdAt: { gte: since },
status: 'APPROVED',
},
include: {
submittedByUser: { select: { id: true, name: true, email: true } },
campaign: { select: { id: true, title: true, slug: true } },
},
orderBy: { createdAt: 'desc' },
take: FEED_MAX_ITEMS,
});
return responses
.filter((r) => r.submittedByUser)
.map((r) => ({
id: `response:${r.id}`,
type: 'response_submitted' as const,
userId: r.submittedByUser!.id,
userName: r.submittedByUser!.name,
userEmail: r.submittedByUser!.email,
title: 'Submitted a representative response',
description: `Response from ${r.representativeName} on ${r.campaign.title}`,
metadata: { campaignId: r.campaign.id, representativeName: r.representativeName },
timestamp: r.createdAt,
}));
},
};

View File

@@ -0,0 +1,133 @@
import { Router } from 'express';
import type { Request, Response } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { friendRequestRateLimit } from './social.rate-limits';
import { friendshipService } from './friendship.service';
import { sendFriendRequestSchema, friendsPaginationSchema } from './social.schemas';
import { checkSocialEnabled } from './social.middleware';
const router = Router();
router.use(authenticate);
router.use(checkSocialEnabled);
/** GET /api/social/friends — list accepted friends (paginated) */
router.get('/', async (req: Request, res: Response) => {
try {
const { page, limit } = friendsPaginationSchema.parse(req.query);
const result = await friendshipService.listFriends(req.user!.id, page, limit);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'FRIENDS_LIST_ERROR' } });
}
});
/** GET /api/social/friends/pending/received — incoming requests */
router.get('/pending/received', async (req: Request, res: Response) => {
try {
const requests = await friendshipService.listPendingReceived(req.user!.id);
res.json({ requests });
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'PENDING_RECEIVED_ERROR' } });
}
});
/** GET /api/social/friends/pending/sent — outgoing requests */
router.get('/pending/sent', async (req: Request, res: Response) => {
try {
const requests = await friendshipService.listPendingSent(req.user!.id);
res.json({ requests });
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'PENDING_SENT_ERROR' } });
}
});
/** POST /api/social/friends/request — send friend request */
router.post('/request', friendRequestRateLimit, async (req: Request, res: Response) => {
try {
const { userId } = sendFriendRequestSchema.parse(req.body);
const friendship = await friendshipService.sendRequest(req.user!.id, userId);
res.status(201).json(friendship);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'FRIEND_REQUEST_ERROR' } });
}
});
/** POST /api/social/friends/:id/accept — accept request */
router.post('/:id/accept', async (req: Request, res: Response) => {
try {
const friendshipId = parseInt(req.params.id as string, 10);
if (isNaN(friendshipId)) {
res.status(400).json({ error: { message: 'Invalid friendship ID', code: 'INVALID_ID' } });
return;
}
const friendship = await friendshipService.acceptRequest(req.user!.id, friendshipId);
res.json(friendship);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'ACCEPT_ERROR' } });
}
});
/** POST /api/social/friends/:id/decline — decline request */
router.post('/:id/decline', async (req: Request, res: Response) => {
try {
const friendshipId = parseInt(req.params.id as string, 10);
if (isNaN(friendshipId)) {
res.status(400).json({ error: { message: 'Invalid friendship ID', code: 'INVALID_ID' } });
return;
}
const friendship = await friendshipService.declineRequest(req.user!.id, friendshipId);
res.json(friendship);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'DECLINE_ERROR' } });
}
});
/** DELETE /api/social/friends/:id/cancel — cancel sent request */
router.delete('/:id/cancel', async (req: Request, res: Response) => {
try {
const friendshipId = parseInt(req.params.id as string, 10);
if (isNaN(friendshipId)) {
res.status(400).json({ error: { message: 'Invalid friendship ID', code: 'INVALID_ID' } });
return;
}
const result = await friendshipService.cancelRequest(req.user!.id, friendshipId);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'CANCEL_ERROR' } });
}
});
/** DELETE /api/social/friends/:friendId — unfriend */
router.delete('/:friendId', async (req: Request, res: Response) => {
try {
const friendId = req.params.friendId as string;
const result = await friendshipService.unfriend(req.user!.id, friendId);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'UNFRIEND_ERROR' } });
}
});
/** GET /api/social/friends/status/:userId — check relationship */
router.get('/status/:userId', async (req: Request, res: Response) => {
try {
const otherUserId = req.params.userId as string;
const status = await friendshipService.getFriendshipStatus(req.user!.id, otherUserId);
res.json(status);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'STATUS_ERROR' } });
}
});
/** GET /api/social/friends/mutual/:userId — mutual friends */
router.get('/mutual/:userId', async (req: Request, res: Response) => {
try {
const otherUserId = req.params.userId as string;
const result = await friendshipService.getMutualFriends(req.user!.id, otherUserId);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'MUTUAL_FRIENDS_ERROR' } });
}
});
export { router as friendshipRouter };

View File

@@ -0,0 +1,371 @@
import { prisma } from '../../config/database';
import type { FriendshipStatus } from '@prisma/client';
import { notificationService } from './notification.service';
import { achievementsService } from './achievements.service';
import { sseService } from './sse.service';
const FRIEND_SELECT = {
id: true,
email: true,
name: true,
role: true,
createdAt: true,
} as const;
export const friendshipService = {
/** Send a friend request (creates pending Friendship) */
async sendRequest(userId: string, friendId: string) {
// Validation: cannot friend yourself
if (userId === friendId) {
throw Object.assign(new Error('Cannot send friend request to yourself'), { statusCode: 400 });
}
// Check target user exists
const target = await prisma.user.findUnique({ where: { id: friendId }, select: { id: true } });
if (!target) {
throw Object.assign(new Error('User not found'), { statusCode: 404 });
}
// Check block in either direction
const blocked = await this.isBlocked(userId, friendId);
if (blocked) {
throw Object.assign(new Error('Cannot send friend request'), { statusCode: 403 });
}
// Check target privacy settings
const privacy = await prisma.privacySettings.findUnique({ where: { userId: friendId } });
if (privacy?.allowFriendRequests === false) {
throw Object.assign(new Error('This user is not accepting friend requests'), { statusCode: 403 });
}
// Check for existing friendship in either direction
const existing = await prisma.friendship.findFirst({
where: {
OR: [
{ userId, friendId },
{ userId: friendId, friendId: userId },
],
},
});
if (existing) {
if (existing.status === 'accepted') {
throw Object.assign(new Error('Already friends'), { statusCode: 409 });
}
if (existing.status === 'pending') {
// If they already sent us a request, auto-accept
if (existing.userId === friendId) {
return this.acceptRequest(userId, existing.id);
}
throw Object.assign(new Error('Friend request already sent'), { statusCode: 409 });
}
if (existing.status === 'declined') {
// Allow re-sending after decline — update existing record
return prisma.friendship.update({
where: { id: existing.id },
data: { userId, friendId, status: 'pending', acceptedAt: null },
include: { user: { select: FRIEND_SELECT }, friend: { select: FRIEND_SELECT } },
});
}
}
const friendship = await prisma.friendship.create({
data: { userId, friendId, status: 'pending' },
include: { user: { select: FRIEND_SELECT }, friend: { select: FRIEND_SELECT } },
});
// Notify the recipient
const senderName = friendship.user.name || friendship.user.email;
notificationService.createNotification(
friendId,
'friend_request',
'New Friend Request',
`${senderName} sent you a friend request`,
{ friendshipId: friendship.id, fromUserId: userId },
).catch(() => {}); // fire-and-forget
// Push real-time SSE event to recipient
sseService.sendToUser(friendId, 'friend_request', {
friendshipId: friendship.id,
from: friendship.user,
});
return friendship;
},
/** Accept an incoming friend request */
async acceptRequest(userId: string, friendshipId: number) {
const friendship = await prisma.friendship.findUnique({
where: { id: friendshipId },
});
if (!friendship) {
throw Object.assign(new Error('Friend request not found'), { statusCode: 404 });
}
if (friendship.friendId !== userId) {
throw Object.assign(new Error('Not your friend request to accept'), { statusCode: 403 });
}
if (friendship.status !== 'pending') {
throw Object.assign(new Error('Request is not pending'), { statusCode: 400 });
}
const updated = await prisma.friendship.update({
where: { id: friendshipId },
data: { status: 'accepted', acceptedAt: new Date() },
include: { user: { select: FRIEND_SELECT }, friend: { select: FRIEND_SELECT } },
});
// Notify the original sender that their request was accepted
const accepterName = updated.friend.name || updated.friend.email;
notificationService.createNotification(
updated.userId, // the original sender
'friend_accepted',
'Friend Request Accepted',
`${accepterName} accepted your friend request`,
{ friendshipId: updated.id, fromUserId: userId },
).catch(() => {});
// Push real-time SSE event to original sender
sseService.sendToUser(updated.userId, 'friend_accepted', {
friendshipId: updated.id,
friend: updated.friend,
});
// Achievement check for both users (fire-and-forget)
achievementsService.checkAndUnlock(userId, ['social']).catch(() => {});
achievementsService.checkAndUnlock(updated.userId, ['social']).catch(() => {});
return updated;
},
/** Decline an incoming friend request */
async declineRequest(userId: string, friendshipId: number) {
const friendship = await prisma.friendship.findUnique({
where: { id: friendshipId },
});
if (!friendship) {
throw Object.assign(new Error('Friend request not found'), { statusCode: 404 });
}
if (friendship.friendId !== userId) {
throw Object.assign(new Error('Not your friend request to decline'), { statusCode: 403 });
}
if (friendship.status !== 'pending') {
throw Object.assign(new Error('Request is not pending'), { statusCode: 400 });
}
return prisma.friendship.update({
where: { id: friendshipId },
data: { status: 'declined' },
});
},
/** Cancel an outgoing friend request */
async cancelRequest(userId: string, friendshipId: number) {
const friendship = await prisma.friendship.findUnique({
where: { id: friendshipId },
});
if (!friendship) {
throw Object.assign(new Error('Friend request not found'), { statusCode: 404 });
}
if (friendship.userId !== userId) {
throw Object.assign(new Error('Not your friend request to cancel'), { statusCode: 403 });
}
if (friendship.status !== 'pending') {
throw Object.assign(new Error('Request is not pending'), { statusCode: 400 });
}
await prisma.friendship.delete({ where: { id: friendshipId } });
return { success: true };
},
/** Remove an accepted friendship */
async unfriend(userId: string, friendId: string) {
const friendship = await prisma.friendship.findFirst({
where: {
OR: [
{ userId, friendId, status: 'accepted' },
{ userId: friendId, friendId: userId, status: 'accepted' },
],
},
});
if (!friendship) {
throw Object.assign(new Error('Not friends'), { statusCode: 404 });
}
// Also remove from close friends
await prisma.$transaction([
prisma.friendship.delete({ where: { id: friendship.id } }),
prisma.closeFriend.deleteMany({
where: {
OR: [
{ userId, closeFriendId: friendId },
{ userId: friendId, closeFriendId: userId },
],
},
}),
]);
return { success: true };
},
/** List accepted friends (paginated) */
async listFriends(userId: string, page: number, limit: number) {
const skip = (page - 1) * limit;
const [friendships, total] = await Promise.all([
prisma.friendship.findMany({
where: {
OR: [
{ userId, status: 'accepted' },
{ friendId: userId, status: 'accepted' },
],
},
include: {
user: { select: FRIEND_SELECT },
friend: { select: FRIEND_SELECT },
},
orderBy: { acceptedAt: 'desc' },
skip,
take: limit,
}),
prisma.friendship.count({
where: {
OR: [
{ userId, status: 'accepted' },
{ friendId: userId, status: 'accepted' },
],
},
}),
]);
// Return the "other" user for each friendship
const friends = friendships.map((f) => ({
friendshipId: f.id,
acceptedAt: f.acceptedAt,
user: f.userId === userId ? f.friend : f.user,
}));
return {
friends,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
/** List incoming pending requests */
async listPendingReceived(userId: string) {
const requests = await prisma.friendship.findMany({
where: { friendId: userId, status: 'pending' },
include: { user: { select: FRIEND_SELECT } },
orderBy: { createdAt: 'desc' },
});
return requests.map((r) => ({
friendshipId: r.id,
createdAt: r.createdAt,
from: r.user,
}));
},
/** List outgoing pending requests */
async listPendingSent(userId: string) {
const requests = await prisma.friendship.findMany({
where: { userId, status: 'pending' },
include: { friend: { select: FRIEND_SELECT } },
orderBy: { createdAt: 'desc' },
});
return requests.map((r) => ({
friendshipId: r.id,
createdAt: r.createdAt,
to: r.friend,
}));
},
/** Get friendship status between two users */
async getFriendshipStatus(userId: string, otherUserId: string) {
if (userId === otherUserId) {
return { status: 'self' as const };
}
const blocked = await this.isBlocked(userId, otherUserId);
if (blocked) {
return { status: 'blocked' as const };
}
const friendship = await prisma.friendship.findFirst({
where: {
OR: [
{ userId, friendId: otherUserId },
{ userId: otherUserId, friendId: userId },
],
},
});
if (!friendship) {
return { status: 'none' as const };
}
const direction = friendship.userId === userId ? 'sent' : 'received';
return {
status: friendship.status as FriendshipStatus,
friendshipId: friendship.id,
direction,
};
},
/** Get mutual friends between two users */
async getMutualFriends(userId: string, otherUserId: string) {
// Get friend IDs for both users
const [myFriends, theirFriends] = await Promise.all([
this.getFriendIds(userId),
this.getFriendIds(otherUserId),
]);
const mySet = new Set(myFriends);
const mutualIds = theirFriends.filter((id) => mySet.has(id));
if (mutualIds.length === 0) {
return { count: 0, users: [] };
}
const users = await prisma.user.findMany({
where: { id: { in: mutualIds } },
select: FRIEND_SELECT,
take: 10,
});
return { count: mutualIds.length, users };
},
/** Get all accepted friend IDs for a user */
async getFriendIds(userId: string): Promise<string[]> {
const friendships = await prisma.friendship.findMany({
where: {
OR: [
{ userId, status: 'accepted' },
{ friendId: userId, status: 'accepted' },
],
},
select: { userId: true, friendId: true },
});
return friendships.map((f) => (f.userId === userId ? f.friendId : f.userId));
},
/** Check if either user has blocked the other */
async isBlocked(userId: string, otherUserId: string): Promise<boolean> {
const block = await prisma.userBlock.findFirst({
where: {
OR: [
{ userId, blockedUserId: otherUserId },
{ userId: otherUserId, blockedUserId: userId },
],
},
});
return !!block;
},
};

View File

@@ -0,0 +1,87 @@
import { Router } from 'express';
import type { Request, Response } from 'express';
import { checkSocialEnabled } from './social.middleware';
import { groupService } from './group.service';
import { generateModeratorToken } from '../jitsi/jitsi.utils';
import { prisma } from '../../config/database';
const router = Router();
router.use(checkSocialEnabled);
/** GET /api/social/groups — list my groups */
router.get('/', async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const limit = Math.min(50, Math.max(1, parseInt(req.query.limit as string) || 20));
const result = await groupService.listMyGroups(userId, page, limit);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message } });
}
});
/** GET /api/social/groups/:id — group detail with members */
router.get('/:id', async (req: Request, res: Response) => {
try {
const groupId = req.params.id as string;
const result = await groupService.getGroupDetail(groupId);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message } });
}
});
/** POST /api/social/groups/:id/call/start — start group call */
router.post('/:id/call/start', async (req: Request, res: Response) => {
try {
// Check enableMeet
const settings = await prisma.siteSettings.findFirst({ select: { enableMeet: true } });
if (!settings?.enableMeet) {
res.status(404).json({ error: { message: 'Video meetings are not enabled' } });
return;
}
const groupId = req.params.id as string;
const result = await groupService.startGroupCall(groupId, req.user!.id);
res.status(201).json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message } });
}
});
/** POST /api/social/groups/:id/call/end — end group call */
router.post('/:id/call/end', async (req: Request, res: Response) => {
try {
const groupId = req.params.id as string;
const result = await groupService.endGroupCall(groupId, req.user!.id);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message } });
}
});
/** POST /api/social/groups/:id/call/token — get moderator JWT for group call */
router.post('/:id/call/token', async (req: Request, res: Response) => {
try {
const groupId = req.params.id as string;
const meeting = await groupService.getCallMeeting(groupId, req.user!.id);
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: { id: true, email: true, name: true },
});
if (!user) {
res.status(404).json({ error: { message: 'User not found' } });
return;
}
const token = generateModeratorToken(user, meeting.jitsiRoom);
res.json({ token, room: meeting.jitsiRoom, slug: meeting.slug });
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message } });
}
});
export { router as groupRouter };

Some files were not shown because too many files have changed in this diff Show More