Okay Wish I could say I know exactly. Will do better next time promise lol
This commit is contained in:
22
api/prisma/init-gitea-db.sh
Executable file
22
api/prisma/init-gitea-db.sh
Executable 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"
|
||||
3823
api/prisma/migrations/20260224100000_baseline_catchup/migration.sql
Normal file
3823
api/prisma/migrations/20260224100000_baseline_catchup/migration.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ads" ADD COLUMN "placements" JSONB DEFAULT '[]';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "site_settings" ADD COLUMN "enable_social" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "site_settings" ADD COLUMN "enable_meet" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
17
api/src/modules/activity/activity-public.routes.ts
Normal file
17
api/src/modules/activity/activity-public.routes.ts
Normal 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 };
|
||||
126
api/src/modules/activity/activity-public.service.ts
Normal file
126
api/src/modules/activity/activity-public.service.ts
Normal 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(),
|
||||
}));
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
266
api/src/modules/docs-comments/docs-comments.routes.ts
Normal file
266
api/src/modules/docs-comments/docs-comments.routes.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
35
api/src/modules/docs-comments/docs-comments.schemas.ts
Normal file
35
api/src/modules/docs-comments/docs-comments.schemas.ts
Normal 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),
|
||||
});
|
||||
331
api/src/modules/docs-comments/docs-comments.service.ts
Normal file
331
api/src/modules/docs-comments/docs-comments.service.ts
Normal 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) };
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -45,6 +45,39 @@ function escapeHtml(str: string): string {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** 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>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
224
api/src/modules/events/events-public.routes.ts
Normal file
224
api/src/modules/events/events-public.routes.ts
Normal 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 };
|
||||
179
api/src/modules/events/unified-calendar.service.ts
Normal file
179
api/src/modules/events/unified-calendar.service.ts
Normal 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 [];
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
16
api/src/modules/homepage/homepage.routes.ts
Normal file
16
api/src/modules/homepage/homepage.routes.ts
Normal 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 };
|
||||
243
api/src/modules/homepage/homepage.service.ts
Normal file
243
api/src/modules/homepage/homepage.service.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
332
api/src/modules/jitsi/jitsi.routes.ts
Normal file
332
api/src/modules/jitsi/jitsi.routes.ts
Normal 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;
|
||||
30
api/src/modules/jitsi/jitsi.utils.ts
Normal file
30
api/src/modules/jitsi/jitsi.utils.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
14
api/src/modules/map/events/events.routes.ts
Normal file
14
api/src/modules/map/events/events.routes.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
176
api/src/modules/map/events/events.service.ts
Normal file
176
api/src/modules/map/events/events.service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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' }],
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
|
||||
77
api/src/modules/newsletter/newsletter-public.routes.ts
Normal file
77
api/src/modules/newsletter/newsletter-public.routes.ts
Normal 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 };
|
||||
170
api/src/modules/og/og.routes.ts
Normal file
170
api/src/modules/og/og.routes.ts
Normal 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 };
|
||||
@@ -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',
|
||||
|
||||
92
api/src/modules/payments/donation-pages-admin.routes.ts
Normal file
92
api/src/modules/payments/donation-pages-admin.routes.ts
Normal 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 };
|
||||
67
api/src/modules/payments/donation-pages-public.routes.ts
Normal file
67
api/src/modules/payments/donation-pages-public.routes.ts
Normal 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 };
|
||||
39
api/src/modules/payments/donation-pages.schemas.ts
Normal file
39
api/src/modules/payments/donation-pages.schemas.ts
Normal 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(),
|
||||
});
|
||||
283
api/src/modules/payments/donation-pages.service.ts
Normal file
283
api/src/modules/payments/donation-pages.service.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
186
api/src/modules/payments/plans.service.ts
Normal file
186
api/src/modules/payments/plans.service.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
753
api/src/modules/people/people.routes.ts
Normal file
753
api/src/modules/people/people.routes.ts
Normal 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 };
|
||||
147
api/src/modules/people/people.schemas.ts
Normal file
147
api/src/modules/people/people.schemas.ts
Normal 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>;
|
||||
2541
api/src/modules/people/people.service.ts
Normal file
2541
api/src/modules/people/people.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
254
api/src/modules/people/profile-public.routes.ts
Normal file
254
api/src/modules/people/profile-public.routes.ts
Normal 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 };
|
||||
33
api/src/modules/people/profile-public.schemas.ts
Normal file
33
api/src/modules/people/profile-public.schemas.ts
Normal 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>;
|
||||
690
api/src/modules/people/profile.service.ts
Normal file
690
api/src/modules/people/profile.service.ts
Normal 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();
|
||||
33
api/src/modules/people/tags.schemas.ts
Normal file
33
api/src/modules/people/tags.schemas.ts
Normal 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>;
|
||||
452
api/src/modules/people/tags.service.ts
Normal file
452
api/src/modules/people/tags.service.ts
Normal 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 } },
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
37
api/src/modules/search/search.routes.ts
Normal file
37
api/src/modules/search/search.routes.ts
Normal 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 };
|
||||
158
api/src/modules/search/search.service.ts
Normal file
158
api/src/modules/search/search.service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
108
api/src/modules/sms/campaigns/sms-campaigns.routes.ts
Normal file
108
api/src/modules/sms/campaigns/sms-campaigns.routes.ts
Normal 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;
|
||||
18
api/src/modules/sms/campaigns/sms-campaigns.schemas.ts
Normal file
18
api/src/modules/sms/campaigns/sms-campaigns.schemas.ts
Normal 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>;
|
||||
172
api/src/modules/sms/campaigns/sms-campaigns.service.ts
Normal file
172
api/src/modules/sms/campaigns/sms-campaigns.service.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
109
api/src/modules/sms/contacts/sms-contacts.routes.ts
Normal file
109
api/src/modules/sms/contacts/sms-contacts.routes.ts
Normal 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;
|
||||
20
api/src/modules/sms/contacts/sms-contacts.schemas.ts
Normal file
20
api/src/modules/sms/contacts/sms-contacts.schemas.ts
Normal 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>;
|
||||
294
api/src/modules/sms/contacts/sms-contacts.service.ts
Normal file
294
api/src/modules/sms/contacts/sms-contacts.service.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
139
api/src/modules/sms/conversations/sms-conversations.service.ts
Normal file
139
api/src/modules/sms/conversations/sms-conversations.service.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
43
api/src/modules/sms/device/sms-device.routes.ts
Normal file
43
api/src/modules/sms/device/sms-device.routes.ts
Normal 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;
|
||||
55
api/src/modules/sms/device/sms-device.service.ts
Normal file
55
api/src/modules/sms/device/sms-device.service.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
46
api/src/modules/sms/messages/sms-messages.routes.ts
Normal file
46
api/src/modules/sms/messages/sms-messages.routes.ts
Normal 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;
|
||||
70
api/src/modules/sms/messages/sms-messages.service.ts
Normal file
70
api/src/modules/sms/messages/sms-messages.service.ts
Normal 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 } },
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
219
api/src/modules/sms/setup/sms-setup.routes.ts
Normal file
219
api/src/modules/sms/setup/sms-setup.routes.ts
Normal 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;
|
||||
99
api/src/modules/social/achievements.routes.ts
Normal file
99
api/src/modules/social/achievements.routes.ts
Normal 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;
|
||||
391
api/src/modules/social/achievements.service.ts
Normal file
391
api/src/modules/social/achievements.service.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
49
api/src/modules/social/block.routes.ts
Normal file
49
api/src/modules/social/block.routes.ts
Normal 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 };
|
||||
97
api/src/modules/social/block.service.ts
Normal file
97
api/src/modules/social/block.service.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
34
api/src/modules/social/feed.routes.ts
Normal file
34
api/src/modules/social/feed.routes.ts
Normal 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 };
|
||||
238
api/src/modules/social/feed.service.ts
Normal file
238
api/src/modules/social/feed.service.ts
Normal 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,
|
||||
}));
|
||||
},
|
||||
};
|
||||
133
api/src/modules/social/friendship.routes.ts
Normal file
133
api/src/modules/social/friendship.routes.ts
Normal 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 };
|
||||
371
api/src/modules/social/friendship.service.ts
Normal file
371
api/src/modules/social/friendship.service.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
87
api/src/modules/social/group.routes.ts
Normal file
87
api/src/modules/social/group.routes.ts
Normal 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
Reference in New Issue
Block a user