Tonne of udpatess

This commit is contained in:
2026-02-18 10:01:54 -07:00
parent 99a6abab06
commit 56e262ad8b
197 changed files with 42200 additions and 968 deletions

View File

@@ -12,6 +12,9 @@ RUN npx prisma generate
# Development stage
FROM base AS development
COPY . .
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["npm", "run", "dev"]
# Build stage
@@ -26,4 +29,7 @@ COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
COPY --from=build /app/prisma ./prisma
COPY --from=build /app/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["npm", "start"]

13
api/docker-entrypoint.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
set -e
echo "Running Prisma schema sync..."
npx prisma db push --skip-generate 2>&1
echo "Schema sync complete."
echo "Running database seed..."
npx prisma db seed 2>&1
echo "Seed complete."
echo "Starting server..."
exec "$@"

17
api/package-lock.json generated
View File

@@ -35,6 +35,7 @@
"prom-client": "^15.1.3",
"qrcode": "^1.5.4",
"rate-limit-redis": "^4.2.0",
"stripe": "^20.3.1",
"winston": "^3.17.0",
"yaml": "^2.8.2",
"zod": "^3.24.1"
@@ -4799,6 +4800,22 @@
"node": ">=8"
}
},
"node_modules/stripe": {
"version": "20.3.1",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.3.1.tgz",
"integrity": "sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==",
"engines": {
"node": ">=16"
},
"peerDependencies": {
"@types/node": ">=16"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/tdigest": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz",

View File

@@ -43,6 +43,7 @@
"prom-client": "^15.1.3",
"qrcode": "^1.5.4",
"rate-limit-redis": "^4.2.0",
"stripe": "^20.3.1",
"winston": "^3.17.0",
"yaml": "^2.8.2",
"zod": "^3.24.1"

View File

@@ -130,6 +130,7 @@ model User {
invoices Invoice[] @relation("UserInvoices")
payments Payment[] @relation("UserPayments")
paymentAudits PaymentAuditLog[] @relation("PaymentAuditUser")
orders Order[] @relation("UserOrders")
notifications Notification[] @relation("UserNotifications")
notificationPreferences NotificationPreferences? @relation("NotificationPreferences")
@@ -293,6 +294,8 @@ model CampaignEmail {
@@index([campaignId])
@@index([campaignSlug])
@@index([userPostalCode])
@@index([sentAt])
@@map("campaign_emails")
}
@@ -357,6 +360,7 @@ model RepresentativeResponse {
@@index([campaignId])
@@index([campaignSlug])
@@index([representativeName])
@@map("representative_responses")
}
@@ -779,6 +783,7 @@ model MapSettings {
qrCode2Label String?
qrCode3Url String?
qrCode3Label String?
publicMapEnabled Boolean @default(true)
createdBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -836,6 +841,9 @@ model SiteSettings {
enableMap Boolean @default(true)
enableNewsletter Boolean @default(true)
enableLandingPages Boolean @default(true)
enableMediaFeatures Boolean @default(true) @map("enable_media_features")
enablePayments Boolean @default(false)
enableGalleryAds Boolean @default(false) @map("enable_gallery_ads")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -851,6 +859,7 @@ enum EmailTemplateCategory {
INFLUENCE
MAP
SYSTEM
PAYMENT
}
enum EmailTemplateVariableType {
@@ -1305,6 +1314,7 @@ enum SubscriptionStatus {
grace_period
delinquent
lifetime
cancelled
}
enum InvoiceStatus {
@@ -1318,12 +1328,27 @@ enum PaymentStatus {
pending
succeeded
failed
refunded
}
enum PaymentMethod {
card
bank_transfer
crypto
stripe
}
enum ProductType {
DIGITAL
EVENT
DONATION
}
enum OrderStatus {
PENDING
COMPLETED
FAILED
REFUNDED
}
enum NotificationType {
@@ -1402,6 +1427,9 @@ model Video {
averageWatchTimeSeconds Decimal @default(0) @map("average_watch_time_seconds") @db.Decimal(10, 2)
completionRate Decimal @default(0) @map("completion_rate") @db.Decimal(5, 2)
// Content gating
accessLevel String @default("free") @map("access_level") // free|member|premium
// Ordering
position Int? @default(0)
@@ -2018,17 +2046,28 @@ model Ad {
imagePath String? @map("image_path")
linkUrl String? @map("link_url")
title String?
subtitle String? @db.Text
ctaText String? @map("cta_text")
ctaStyle String? @default("primary") @map("cta_style")
bgColor String? @map("bg_color")
iconEmoji String? @map("icon_emoji")
isSystemAd Boolean @default(false) @map("is_system_ad")
frequency Int @default(6)
visibility String @default("everyone")
isActive Boolean? @default(true) @map("is_active")
position Int? @default(0)
impressionCount Int? @default(0) @map("impression_count")
clickCount Int? @default(0) @map("click_count")
startDate DateTime? @map("start_date")
endDate DateTime? @map("end_date")
productId String? @unique @map("product_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @map("updated_at")
// Relations
impressions AdImpression[]
clicks AdClick[]
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
@@index([type], map: "idx_ads_type")
@@index([isActive], map: "idx_ads_is_active")
@@ -3039,13 +3078,22 @@ model PipelineTemplate {
// ============================================================================
model SubscriptionPlan {
id Int @id @default(autoincrement())
name String
priceCAD Int @map("price_cad")
durationDays Int @map("duration_days")
features Json?
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
name String
priceCAD Int @map("price_cad")
durationDays Int @map("duration_days")
features Json?
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
// Stripe integration
stripeProductId String? @map("stripe_product_id")
stripePriceId String? @map("stripe_price_id")
stripeYearlyPriceId String? @map("stripe_yearly_price_id")
yearlyPriceCAD Int? @map("yearly_price_cad")
description String? @db.Text
tier Int @default(0)
displayOrder Int @default(0) @map("display_order")
// Relations
subscriptions UserSubscription[]
@@ -3054,14 +3102,20 @@ model SubscriptionPlan {
}
model UserSubscription {
id Int @id @default(autoincrement())
userId String @map("user_id")
planId Int @map("plan_id")
status SubscriptionStatus @default(active)
startDate DateTime @map("start_date")
endDate DateTime @map("end_date")
cancelledAt DateTime? @map("cancelled_at")
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
userId String @map("user_id")
planId Int @map("plan_id")
status SubscriptionStatus @default(active)
startDate DateTime @map("start_date")
endDate DateTime @map("end_date")
cancelledAt DateTime? @map("cancelled_at")
createdAt DateTime @default(now()) @map("created_at")
// Stripe integration
stripeSubscriptionId String? @unique @map("stripe_subscription_id")
stripeCustomerId String? @map("stripe_customer_id")
currentPeriodEnd DateTime? @map("current_period_end")
cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end")
// Relations
user User @relation("UserSubscriptions", fields: [userId], references: [id])
@@ -3085,6 +3139,10 @@ model Invoice {
description String?
metadata Json?
// Stripe integration
stripeInvoiceId String? @unique @map("stripe_invoice_id")
type String @default("subscription") @map("invoice_type") // subscription|product|donation
// Relations
user User @relation("UserInvoices", fields: [userId], references: [id])
payments Payment[]
@@ -3096,16 +3154,20 @@ model Invoice {
}
model Payment {
id Int @id @default(autoincrement())
invoiceId Int @map("invoice_id")
userId String @map("user_id")
amountCAD Int @map("amount_cad")
method PaymentMethod
status PaymentStatus @default(pending)
externalId String? @map("external_id")
metadata Json?
processedAt DateTime? @map("processed_at")
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
invoiceId Int @map("invoice_id")
userId String @map("user_id")
amountCAD Int @map("amount_cad")
method PaymentMethod
status PaymentStatus @default(pending)
externalId String? @map("external_id")
metadata Json?
processedAt DateTime? @map("processed_at")
createdAt DateTime @default(now()) @map("created_at")
// Stripe integration
stripePaymentIntentId String? @unique @map("stripe_payment_intent_id")
stripeCheckoutSessionId String? @map("stripe_checkout_session_id")
// Relations
invoice Invoice @relation(fields: [invoiceId], references: [id])
@@ -3138,6 +3200,87 @@ model PaymentAuditLog {
@@map("payment_audit_log")
}
model Product {
id String @id @default(cuid())
slug String @unique
title String
description String? @db.Text
priceCAD Int @map("price_cad")
type ProductType
stripeProductId String? @map("stripe_product_id")
stripePriceId String? @map("stripe_price_id")
isActive Boolean @default(true) @map("is_active")
imageUrl String? @map("image_url")
downloadUrl String? @map("download_url")
metadata Json?
maxPurchases Int? @map("max_purchases")
purchaseCount Int @default(0) @map("purchase_count")
createdByUserId String? @map("created_by_user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
orders Order[]
galleryAd Ad?
@@index([type], map: "idx_products_type")
@@index([isActive], map: "idx_products_active")
@@map("products")
}
model Order {
id String @id @default(cuid())
userId String? @map("user_id")
productId String? @map("product_id")
amountCAD Int @map("amount_cad")
status OrderStatus @default(PENDING)
stripeCheckoutSessionId String? @unique @map("stripe_checkout_session_id")
stripePaymentIntentId String? @map("stripe_payment_intent_id")
type String @default("product") @map("order_type") // product|donation
// Buyer info (for guests)
buyerEmail String @map("buyer_email")
buyerName String? @map("buyer_name")
// Donation-specific
donorMessage String? @db.Text @map("donor_message")
isAnonymous Boolean @default(false) @map("is_anonymous")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User? @relation("UserOrders", fields: [userId], references: [id])
product Product? @relation(fields: [productId], references: [id])
@@index([userId], map: "idx_orders_user")
@@index([productId], map: "idx_orders_product")
@@index([status], map: "idx_orders_status")
@@index([type], map: "idx_orders_type")
@@map("orders")
}
model PaymentSettings {
id String @id @default(cuid())
stripeSecretKey String @default("") @map("stripe_secret_key")
stripePublishableKey String @default("") @map("stripe_publishable_key")
stripeWebhookSecret String @default("") @map("stripe_webhook_secret")
defaultCurrency String @default("cad") @map("default_currency")
// Donation settings
enableDonations Boolean @default(true) @map("enable_donations")
donationSuggestedAmounts Json @default("[1000, 2500, 5000, 10000]") @map("donation_suggested_amounts")
donationMinimum Int @default(500) @map("donation_minimum")
donationPageTitle String @default("Support Our Work") @map("donation_page_title")
donationPageDescription String? @db.Text @map("donation_page_description")
thankYouMessage String @default("Thank you for your support!") @db.Text @map("thank_you_message")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("payment_settings")
}
// ============================================================================
// NOTIFICATIONS
// ============================================================================
@@ -3299,3 +3442,20 @@ model VideoScheduleHistory {
@@index([scheduledByUserId], map: "idx_video_schedule_history_user")
@@map("video_schedule_history")
}
// ============================================================================
// DOCS ANALYTICS
// ============================================================================
model DocsPageView {
id String @id @default(cuid())
path String // e.g. "/docs/getting-started/"
referrer String? @db.Text // document.referrer
sessionHash String? // anonymous session UUID (sessionStorage)
userAgent String? // for device type breakdown
createdAt DateTime @default(now())
@@index([createdAt])
@@index([path, createdAt])
@@map("docs_page_views")
}

View File

@@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs';
import * as fs from 'fs';
import * as path from 'path';
import { env } from '../src/config/env';
import { initEncryption, encrypt } from '../src/utils/crypto';
const prisma = new PrismaClient();
@@ -77,12 +78,32 @@ async function main() {
console.log('Created default map settings');
// Phase 3: v1 data migration will go here
// - Export NocoDB data via API
// - Import influence_users + login tables → unified users
// - Deduplicate by email
// - Hash plaintext passwords
// - Import campaigns, locations, shifts, cuts, etc.
// Seed SiteSettings with .env SMTP values (only if no row exists yet)
const existingSettings = await prisma.siteSettings.findFirst();
if (!existingSettings) {
// Initialize encryption so we can encrypt the SMTP password
const encryptionKey = env.ENCRYPTION_KEY || env.JWT_ACCESS_SECRET;
initEncryption(encryptionKey);
const isMailhog = env.EMAIL_TEST_MODE === 'true' || env.SMTP_HOST === 'mailhog-changemaker';
await prisma.siteSettings.create({
data: {
smtpHost: env.SMTP_HOST,
smtpPort: env.SMTP_PORT,
smtpUser: env.SMTP_USER,
smtpPass: env.SMTP_PASS ? encrypt(env.SMTP_PASS) : '',
smtpFromAddress: env.SMTP_FROM,
emailFromName: env.SMTP_FROM_NAME,
smtpActiveProvider: isMailhog ? 'mailhog' : 'production',
emailTestMode: env.EMAIL_TEST_MODE === 'true',
testEmailRecipient: env.TEST_EMAIL_RECIPIENT,
},
});
console.log('Created SiteSettings with SMTP config from .env');
} else {
console.log('SiteSettings already exists, skipping SMTP seeding');
}
// Create default page blocks for landing page builder
const defaultBlocks = [
@@ -239,6 +260,57 @@ async function main() {
viewCount: 0,
},
},
{
id: 'default-donate-button',
type: 'donate-button',
label: 'Donate Button',
category: 'Payments',
sortOrder: 9,
schema: {
buttonText: { type: 'string', label: 'Button Text', default: 'Donate Now' },
showAmounts: { type: 'boolean', label: 'Show Suggested Amounts', default: true },
heading: { type: 'string', label: 'Heading', default: 'Support Our Cause' },
description: { type: 'string', label: 'Description' },
},
defaults: {
buttonText: 'Donate Now',
showAmounts: true,
heading: 'Support Our Cause',
description: 'Your contribution helps us create lasting change in our community.',
},
},
{
id: 'default-pricing-table',
type: 'pricing-table',
label: 'Pricing Table',
category: 'Payments',
sortOrder: 10,
schema: {
showYearly: { type: 'boolean', label: 'Show Yearly Toggle', default: true },
heading: { type: 'string', label: 'Heading', default: 'Choose Your Plan' },
description: { type: 'string', label: 'Description' },
},
defaults: {
showYearly: true,
heading: 'Choose Your Plan',
description: 'Get access to exclusive content and features.',
},
},
{
id: 'default-product-card',
type: 'product-card',
label: 'Product Card',
category: 'Payments',
sortOrder: 11,
schema: {
productSlug: { type: 'string', label: 'Product Slug', required: true },
buttonText: { type: 'string', label: 'Button Text', default: 'Buy Now' },
},
defaults: {
productSlug: '',
buttonText: 'Buy Now',
},
},
];
for (const block of defaultBlocks) {
@@ -258,6 +330,9 @@ async function main() {
console.warn('⚠️ No admin user found - skipping email template seeding');
}
// Seed pre-made gallery ads (all inactive by default — admin enables manually)
await seedGalleryAds();
console.log('Seed complete.');
}
@@ -404,6 +479,58 @@ async function seedEmailTemplates(admin: { id: string; email: string }) {
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 4 },
],
},
{
key: 'donation-receipt',
name: 'Donation Receipt',
description: 'Receipt email sent to donors after a successful donation payment',
category: EmailTemplateCategory.PAYMENT,
subjectLine: 'Donation Receipt — {{ORGANIZATION_NAME}}',
isSystem: true,
variables: [
{ key: 'RECIPIENT_NAME', label: 'Donor Name', description: 'Name of the donor', isRequired: true, isConditional: false, sampleValue: 'Jane Doe', sortOrder: 0 },
{ key: 'AMOUNT', label: 'Amount', description: 'Donation amount formatted with dollar sign', isRequired: true, isConditional: false, sampleValue: '$25.00', sortOrder: 1 },
{ key: 'ORDER_ID', label: 'Order ID', description: 'Unique reference ID for the donation', isRequired: true, isConditional: false, sampleValue: 'clxyz123abc', sortOrder: 2 },
{ key: 'DONATION_DATE', label: 'Donation Date', description: 'Date the donation was completed', isRequired: true, isConditional: false, sampleValue: 'February 17, 2026', sortOrder: 3 },
{ key: 'DONOR_MESSAGE', label: 'Donor Message', description: 'Optional message from the donor', isRequired: false, isConditional: true, sampleValue: 'Keep up the great work!', sortOrder: 4 },
{ key: 'IS_ANONYMOUS', label: 'Is Anonymous', description: 'Whether the donation is anonymous', isRequired: false, isConditional: true, sampleValue: 'true', sortOrder: 5 },
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 6 },
],
},
{
key: 'product-receipt',
name: 'Product Purchase Receipt',
description: 'Receipt email sent to buyers after a successful product purchase',
category: EmailTemplateCategory.PAYMENT,
subjectLine: 'Purchase Receipt — {{ORGANIZATION_NAME}}',
isSystem: true,
variables: [
{ key: 'RECIPIENT_NAME', label: 'Buyer Name', description: 'Name of the buyer', isRequired: true, isConditional: false, sampleValue: 'John Smith', sortOrder: 0 },
{ key: 'AMOUNT', label: 'Amount', description: 'Purchase amount formatted with dollar sign', isRequired: true, isConditional: false, sampleValue: '$49.99', sortOrder: 1 },
{ key: 'ORDER_ID', label: 'Order ID', description: 'Unique order reference ID', isRequired: true, isConditional: false, sampleValue: 'clxyz456def', sortOrder: 2 },
{ key: 'PRODUCT_TITLE', label: 'Product Title', description: 'Title of the purchased product', isRequired: true, isConditional: false, sampleValue: 'Community Toolkit', sortOrder: 3 },
{ key: 'PRODUCT_TYPE', label: 'Product Type', description: 'Type of product (DIGITAL, EVENT, DONATION)', isRequired: true, isConditional: false, sampleValue: 'DIGITAL', sortOrder: 4 },
{ key: 'PURCHASE_DATE', label: 'Purchase Date', description: 'Date of purchase', isRequired: true, isConditional: false, sampleValue: 'February 17, 2026', sortOrder: 5 },
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 6 },
],
},
{
key: 'subscription-welcome',
name: 'Subscription Welcome',
description: 'Welcome email sent when a user subscribes to a plan',
category: EmailTemplateCategory.PAYMENT,
subjectLine: 'Welcome to {{PLAN_NAME}} — {{ORGANIZATION_NAME}}',
isSystem: true,
variables: [
{ key: 'RECIPIENT_NAME', label: 'User Name', description: 'Name of the subscriber', isRequired: true, isConditional: false, sampleValue: 'Jane Doe', sortOrder: 0 },
{ key: 'PLAN_NAME', label: 'Plan Name', description: 'Name of the subscription plan', isRequired: true, isConditional: false, sampleValue: 'Pro Plan', sortOrder: 1 },
{ key: 'AMOUNT', label: 'Amount', description: 'Subscription price formatted with dollar sign', isRequired: true, isConditional: false, sampleValue: '$9.99', sortOrder: 2 },
{ key: 'FREQUENCY', label: 'Billing Frequency', description: 'How often the subscription renews', isRequired: true, isConditional: false, sampleValue: 'per month', sortOrder: 3 },
{ key: 'RENEWAL_DATE', label: 'Renewal Date', description: 'Next renewal date', isRequired: true, isConditional: false, sampleValue: 'March 17, 2026', sortOrder: 4 },
{ key: 'SUBSCRIPTION_ID', label: 'Subscription ID', description: 'Stripe subscription ID', isRequired: true, isConditional: false, sampleValue: 'sub_1234567890', sortOrder: 5 },
{ key: 'LOGIN_URL', label: 'Login URL', description: 'URL to log in to the platform', isRequired: true, isConditional: false, sampleValue: 'https://app.cmlite.org/login', sortOrder: 6 },
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 7 },
],
},
];
let seededCount = 0;
@@ -476,6 +603,131 @@ async function seedEmailTemplates(admin: { id: string; email: string }) {
console.log(`Email templates seeded: ${seededCount} created, ${skippedCount} skipped`);
}
/**
* Seed pre-made gallery ads
*/
async function seedGalleryAds() {
console.log('Seeding gallery ads...');
const defaultAds = [
{
type: 'system',
variant: 'standard',
title: 'Join the Community',
subtitle: 'Create a free account to upvote, comment, and save favorites',
ctaText: 'Sign Up Free',
ctaStyle: 'primary',
linkUrl: '/login',
visibility: 'anonymous',
frequency: 8,
position: 1,
iconEmoji: null,
bgColor: null,
imagePath: null,
},
{
type: 'payment_subscribe',
variant: 'highlight',
title: 'Unlock Premium Content',
subtitle: 'Subscribe for exclusive videos, early access, and more',
ctaText: 'View Plans',
ctaStyle: 'primary',
linkUrl: '/pricing',
visibility: 'non_subscriber',
frequency: 12,
position: 2,
iconEmoji: null,
bgColor: null,
imagePath: null,
},
{
type: 'payment_donate',
variant: 'standard',
title: 'Support Our Mission',
subtitle: 'Your donation helps us create more content',
ctaText: 'Donate Now',
ctaStyle: 'primary',
linkUrl: '/donate',
visibility: 'everyone',
frequency: 18,
position: 3,
iconEmoji: null,
bgColor: null,
imagePath: null,
},
{
type: 'payment_shop',
variant: 'standard',
title: 'Browse the Shop',
subtitle: 'Exclusive merchandise, downloads, and event tickets',
ctaText: 'Shop Now',
ctaStyle: 'primary',
linkUrl: '/shop',
visibility: 'everyone',
frequency: 24,
position: 4,
iconEmoji: null,
bgColor: null,
imagePath: null,
},
{
type: 'system',
variant: 'standard',
title: 'Take Action',
subtitle: 'Join an advocacy campaign and make your voice heard',
ctaText: 'View Campaigns',
ctaStyle: 'primary',
linkUrl: '/campaigns',
visibility: 'everyone',
frequency: 18,
position: 5,
iconEmoji: null,
bgColor: null,
imagePath: null,
},
{
type: 'system',
variant: 'standard',
title: 'Volunteer With Us',
subtitle: 'Sign up for a shift and help make a difference',
ctaText: 'See Shifts',
ctaStyle: 'primary',
linkUrl: '/shifts',
visibility: 'everyone',
frequency: 24,
position: 6,
iconEmoji: null,
bgColor: null,
imagePath: null,
},
];
let seeded = 0;
let skipped = 0;
for (const ad of defaultAds) {
const existing = await prisma.ad.findFirst({
where: { type: ad.type, title: ad.title },
});
if (existing) {
skipped++;
continue;
}
await prisma.ad.create({
data: {
...ad,
isSystemAd: true,
isActive: false,
},
});
seeded++;
}
console.log(`Gallery ads seeded: ${seeded} created, ${skipped} skipped`);
}
main()
.catch((e) => {
console.error('Seed error:', e);

View File

@@ -123,6 +123,9 @@ const envSchema = z.object({
OVERPASS_MIN_DELAY_MS: z.coerce.number().default(30000),
AREA_IMPORT_MAX_GRID_POINTS: z.coerce.number().default(500),
// Payments (Stripe)
ENABLE_PAYMENTS: z.string().default('false'),
// Media Management
ENABLE_MEDIA_FEATURES: z.string().default('false'),
MEDIA_API_PORT: z.coerce.number().default(4100),

View File

@@ -139,6 +139,23 @@ export const canvassGeocodeRateLimit = rateLimit({
},
});
export const adTrackingRateLimit = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 60, // 60 events/min per IP (generous for scroll-heavy gallery pages)
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:ad-track:',
}),
message: {
error: {
message: 'Too many tracking requests',
code: 'RATE_LIMIT_EXCEEDED',
},
},
});
export const authRateLimit = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // Reduced from 20 to prevent brute force attacks
@@ -173,6 +190,23 @@ export const observabilityRateLimit = rateLimit({
},
});
export const docsAnalyticsRateLimit = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 60, // 60 requests/min per IP
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:docs-analytics:',
}),
message: {
error: {
message: 'Too many tracking requests, please slow down',
code: 'DOCS_ANALYTICS_RATE_LIMIT_EXCEEDED',
},
},
});
export const healthMetricsRateLimit = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 30, // 30 requests per minute

View File

@@ -0,0 +1,64 @@
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 { docsAnalyticsRateLimit } from '../../middleware/rate-limit';
import { docsAnalyticsService } from './docs-analytics.service';
import { trackPageViewSchema, analyticsQuerySchema } from './docs-analytics.schemas';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
// --- Public Router (no auth) ---
export const docsAnalyticsPublicRouter = Router();
// Per-route CORS override: MkDocs runs on a different origin (root domain vs API subdomain)
docsAnalyticsPublicRouter.use((_req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
next();
});
// Handle preflight
docsAnalyticsPublicRouter.options('/track', (_req, res) => {
res.sendStatus(204);
});
// POST /api/docs-analytics/track — record page view (fire-and-forget)
docsAnalyticsPublicRouter.post(
'/track',
docsAnalyticsRateLimit,
validate(trackPageViewSchema),
async (req, res) => {
const { path, referrer, sessionHash } = req.body;
const userAgent = req.headers['user-agent'] || undefined;
// Fire-and-forget: don't await, respond immediately
docsAnalyticsService.recordPageView({ path, referrer, sessionHash, userAgent }).catch(() => {});
res.sendStatus(204);
},
);
// --- Admin Router (auth required) ---
export const docsAnalyticsAdminRouter = Router();
docsAnalyticsAdminRouter.use(authenticate);
docsAnalyticsAdminRouter.use(requireRole(...ADMIN_ROLES));
// GET /api/docs-analytics/summary?days=30
docsAnalyticsAdminRouter.get(
'/summary',
validate(analyticsQuerySchema, 'query'),
async (req, res) => {
const days = Number(req.query.days) || 30;
const summary = await docsAnalyticsService.getSummary(days);
res.json(summary);
},
);
// POST /api/docs-analytics/cleanup — manual cleanup trigger
docsAnalyticsAdminRouter.post('/cleanup', async (_req, res) => {
const deleted = await docsAnalyticsService.cleanupOldData(90);
res.json({ deleted });
});

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
export const trackPageViewSchema = z.object({
path: z.string().min(1).max(2000),
referrer: z.string().max(2000).optional(),
sessionHash: z.string().max(100).optional(),
});
export const analyticsQuerySchema = z.object({
days: z.coerce.number().int().min(1).max(365).default(30),
});

View File

@@ -0,0 +1,140 @@
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
interface PageViewData {
path: string;
referrer?: string;
sessionHash?: string;
userAgent?: string;
}
interface TopPage {
path: string;
views: number;
uniqueSessions: number;
}
interface DayViews {
date: string;
views: number;
uniqueSessions: number;
}
interface TopReferrer {
referrer: string;
count: number;
}
interface AnalyticsSummary {
totalViews: number;
uniqueSessions: number;
topPages: TopPage[];
viewsByDay: DayViews[];
topReferrers: TopReferrer[];
}
type UniqueCountRow = { count: bigint };
type TopPageRow = { path: string; views: bigint; unique_sessions: bigint };
type DayViewRow = { day: Date; views: bigint; unique_sessions: bigint };
type ReferrerRow = { referrer: string; count: bigint };
export const docsAnalyticsService = {
async recordPageView(data: PageViewData): Promise<void> {
await prisma.docsPageView.create({
data: {
path: data.path,
referrer: data.referrer || null,
sessionHash: data.sessionHash || null,
userAgent: data.userAgent || null,
},
});
},
async getSummary(days: number): Promise<AnalyticsSummary> {
const since = new Date();
since.setDate(since.getDate() - days);
const totalViewsP = prisma.docsPageView.count({
where: { createdAt: { gte: since } },
});
const uniqueSessionsP = prisma.$queryRaw<UniqueCountRow[]>`
SELECT COUNT(DISTINCT "sessionHash") as count
FROM docs_page_views
WHERE "createdAt" >= ${since}
AND "sessionHash" IS NOT NULL
`;
const topPagesP = prisma.$queryRaw<TopPageRow[]>`
SELECT path,
COUNT(*) as views,
COUNT(DISTINCT "sessionHash") as unique_sessions
FROM docs_page_views
WHERE "createdAt" >= ${since}
GROUP BY path
ORDER BY views DESC
LIMIT 20
`;
const viewsByDayP = prisma.$queryRaw<DayViewRow[]>`
SELECT DATE("createdAt") as day,
COUNT(*) as views,
COUNT(DISTINCT "sessionHash") as unique_sessions
FROM docs_page_views
WHERE "createdAt" >= ${since}
GROUP BY DATE("createdAt")
ORDER BY day ASC
`;
const topReferrersP = prisma.$queryRaw<ReferrerRow[]>`
SELECT referrer,
COUNT(*) as count
FROM docs_page_views
WHERE "createdAt" >= ${since}
AND referrer IS NOT NULL
AND referrer != ''
GROUP BY referrer
ORDER BY count DESC
LIMIT 10
`;
const [totalViews, uniqueSessionsResult, topPagesRaw, viewsByDayRaw, topReferrersRaw] =
await Promise.all([totalViewsP, uniqueSessionsP, topPagesP, viewsByDayP, topReferrersP]);
return {
totalViews,
uniqueSessions: Number(uniqueSessionsResult[0]?.count ?? 0),
topPages: topPagesRaw.map((r) => ({
path: r.path,
views: Number(r.views),
uniqueSessions: Number(r.unique_sessions),
})),
viewsByDay: viewsByDayRaw.map((r) => ({
date: r.day instanceof Date
? r.day.toISOString().split('T')[0]
: String(r.day),
views: Number(r.views),
uniqueSessions: Number(r.unique_sessions),
})),
topReferrers: topReferrersRaw.map((r) => ({
referrer: r.referrer,
count: Number(r.count),
})),
};
},
async cleanupOldData(retentionDays = 90): Promise<number> {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - retentionDays);
const { count } = await prisma.docsPageView.deleteMany({
where: { createdAt: { lt: cutoff } },
});
if (count > 0) {
logger.info(`Cleaned up ${count} docs page views older than ${retentionDays} days`);
}
return count;
},
};

View File

@@ -10,6 +10,8 @@ import { isServiceOnline } from '../../utils/health-check';
import { cm_docs_operations } from '../../utils/metrics';
import { docsFilesService, PathTraversalError, FileNotFoundError } from './docs-files.service';
import { mkdocsConfigService } from './mkdocs-config.service';
import { headerBuilderService } from './header-builder.service';
import { headerConfigSchema } from './header-builder.schemas';
const router = Router();
router.use(authenticate);
@@ -107,6 +109,46 @@ router.post(
},
);
// --- Header Builder ---
// GET /api/docs/header-config — read header nav bar config
router.get(
'/header-config',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const config = await headerBuilderService.readConfig();
res.json(config);
} catch (err) {
logger.error('Failed to read header config', err);
next(err);
}
},
);
// PUT /api/docs/header-config — save header nav bar config + regenerate template
router.put(
'/header-config',
requireRole('SUPER_ADMIN'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsed = headerConfigSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
error: { message: 'Invalid header config', code: 'VALIDATION_ERROR', details: parsed.error.flatten().fieldErrors },
});
return;
}
await headerBuilderService.writeConfig(parsed.data);
// Invalidate docs file tree cache so the new main.html shows up
await docsFilesService.invalidateTreeCache();
res.json({ success: true });
} catch (err) {
logger.error('Failed to save header config', err);
next(err);
}
},
);
// --- File Upload ---
const ALLOWED_UPLOAD_EXTENSIONS = new Set([

View File

@@ -0,0 +1,29 @@
import { z } from 'zod';
export const headerNavItemSchema = z.object({
id: z.string().min(1),
label: z.string().min(1).max(50),
path: z.string().min(1).max(500),
icon: z.string().max(50).optional(),
enabled: z.boolean(),
order: z.number().int().min(0),
type: z.enum(['builtin', 'custom']),
openInNewTab: z.boolean().optional(),
});
export const headerStyleSchema = z.object({
backgroundColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Must be a hex color'),
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'),
});
export const headerConfigSchema = z.object({
enabled: z.boolean(),
items: z.array(headerNavItemSchema).max(20),
style: headerStyleSchema,
});
export type HeaderNavItem = z.infer<typeof headerNavItemSchema>;
export type HeaderStyle = z.infer<typeof headerStyleSchema>;
export type HeaderConfig = z.infer<typeof headerConfigSchema>;

View File

@@ -0,0 +1,240 @@
import { readFile, writeFile, unlink } from 'fs/promises';
import { resolve as pathResolve } from 'path';
import { existsSync } from 'fs';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
import { headerConfigSchema } from './header-builder.schemas';
import type { HeaderConfig, HeaderNavItem } from './header-builder.schemas';
const OVERRIDES_DIR = pathResolve(env.MKDOCS_DOCS_PATH, 'overrides');
const CONFIG_PATH = pathResolve(OVERRIDES_DIR, 'header-config.json');
const MAIN_HTML_PATH = pathResolve(OVERRIDES_DIR, 'main.html');
/** Default built-in navigation items (pre-populated when no config exists) */
const DEFAULT_ITEMS: HeaderNavItem[] = [
{ id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'campaign', enabled: true, order: 0, type: 'builtin' },
{ id: 'map', label: 'Map', path: '/map', icon: 'map', enabled: true, order: 1, type: 'builtin' },
{ id: 'shifts', label: 'Volunteer', path: '/shifts', icon: 'groups', enabled: true, order: 2, type: 'builtin' },
{ id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'play_circle', enabled: false, order: 3, type: 'builtin' },
{ id: 'responses', label: 'Responses', path: '/responses', icon: 'forum', enabled: false, order: 4, type: 'builtin' },
{ id: 'donate', label: 'Donate', path: '/donate', icon: 'favorite', enabled: false, order: 5, type: 'builtin' },
{ id: 'login', label: 'Sign In', path: '/login', icon: 'login', enabled: true, order: 6, type: 'builtin' },
];
const DEFAULT_CONFIG: HeaderConfig = {
enabled: false,
items: DEFAULT_ITEMS,
style: {
backgroundColor: '#6f42c1',
textColor: '#ffffff',
hoverColor: 'rgba(255,255,255,0.15)',
height: '40px',
},
};
/**
* Escape a string for safe embedding inside a Jinja2/HTML template.
* Prevents XSS if user-supplied labels or paths contain special chars.
*/
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
class HeaderBuilderService {
/**
* Read the current header config from disk.
* Returns defaults if no config file exists.
*/
async readConfig(): Promise<HeaderConfig> {
try {
if (!existsSync(CONFIG_PATH)) {
return { ...DEFAULT_CONFIG };
}
const raw = await readFile(CONFIG_PATH, 'utf-8');
const parsed = JSON.parse(raw);
const validated = headerConfigSchema.parse(parsed);
return validated;
} catch (err) {
logger.warn('Failed to read header config, returning defaults', err);
return { ...DEFAULT_CONFIG };
}
}
/**
* Validate, save config, and regenerate main.html.
*/
async writeConfig(config: HeaderConfig): Promise<void> {
// Validate with Zod
const validated = headerConfigSchema.parse(config);
// Write config JSON
await writeFile(CONFIG_PATH, JSON.stringify(validated, null, 2), 'utf-8');
logger.info('Header config saved');
// Generate or remove main.html
if (validated.enabled) {
const html = this.generateMainHtml(validated);
await writeFile(MAIN_HTML_PATH, html, 'utf-8');
logger.info('Generated main.html with header nav bar');
} else {
// Write minimal passthrough so landing pages still extend main.html
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 (header disabled)');
}
}
/**
* Reset to defaults: remove config file and main.html.
*/
async resetToDefaults(): Promise<void> {
try { await unlink(CONFIG_PATH); } catch { /* file may not exist */ }
try { await unlink(MAIN_HTML_PATH); } catch { /* file may not exist */ }
logger.info('Header config reset to defaults');
}
/**
* Generate the Jinja2 main.html template from config.
*/
generateMainHtml(config: HeaderConfig): 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 { backgroundColor, textColor, hoverColor, height } = config.style;
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">
<nav class="cm-header-nav" role="navigation" aria-label="Application">
<div class="cm-header-nav__inner">
${links}
</div>
</nav>
<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;
if (h === 'localhost' || h === '127.0.0.1') {
base = location.protocol + '//localhost:' + ({{ config.extra.admin_port }} || 3000);
} else {
var parts = h.split('.');
if (parts.length >= 3) { parts[0] = 'app'; }
else { parts.unshift('app'); }
base = location.protocol + '//' + parts.join('.');
}
var links = document.querySelectorAll('.cm-header-nav__link[data-path]');
for (var i = 0; i < links.length; i++) {
links[i].setAttribute('href', base + links[i].getAttribute('data-path'));
}
})();
</script>
<style>
/* Override MkDocs Material announce bar container */
.md-banner {
background: ${escapeHtml(backgroundColor)} !important;
color: ${escapeHtml(textColor)} !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)};
display: flex;
align-items: center;
justify-content: center;
padding: 0 24px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
z-index: 10;
}
.cm-header-nav__inner {
display: flex;
align-items: center;
gap: 6px;
max-width: 1400px;
width: 100%;
justify-content: center;
flex-wrap: nowrap;
overflow-x: auto;
}
.cm-header-nav__link {
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;
white-space: nowrap;
line-height: 1;
}
.cm-header-nav__link:hover {
background: ${escapeHtml(hoverColor)};
color: ${escapeHtml(textColor)};
text-decoration: none;
transform: translateY(-1px);
}
.cm-header-nav__link:active {
transform: translateY(0);
}
.cm-header-nav__link .material-icons {
font-size: 16px;
opacity: 0.9;
}
@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; }
}
</style>
{% endblock %}
`;
}
/**
* Render a single nav link element.
*/
private renderNavLink(item: HeaderNavItem): string {
const isAbsolute = item.path.startsWith('http://') || item.path.startsWith('https://');
const target = item.openInNewTab ? ' target="_blank" rel="noopener noreferrer"' : '';
const iconHtml = item.icon
? `<span class="material-icons">${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>`;
}
// 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>`;
}
}
export const headerBuilderService = new HeaderBuilderService();

View File

@@ -0,0 +1,125 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { galleryAdsService } from './gallery-ads.service';
import { createAdSchema, updateAdSchema, listAdsSchema, reorderAdsSchema, adAnalyticsQuerySchema } from './gallery-ads.schemas';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
const router = Router();
router.use(authenticate);
router.use(requireRole(UserRole.SUPER_ADMIN));
// GET /api/gallery-ads/admin — list all ads (paginated)
router.get(
'/',
validate(listAdsSchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await galleryAdsService.listAll(req.query as any);
res.json(result);
} catch (err) {
next(err);
}
}
);
// GET /api/gallery-ads/admin/:id/analytics — per-ad time-series analytics
router.get(
'/:id/analytics',
validate(adAnalyticsQuerySchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = parseInt(req.params.id as string, 10);
const { days } = req.query as any;
const analytics = await galleryAdsService.getAdAnalytics(id, days);
res.json(analytics);
} catch (err) {
next(err);
}
}
);
// GET /api/gallery-ads/admin/:id — get single ad
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = parseInt(req.params.id as string, 10);
const ad = await galleryAdsService.getById(id);
if (!ad) {
res.status(404).json({ error: 'Ad not found' });
return;
}
res.json(ad);
} catch (err) {
next(err);
}
});
// POST /api/gallery-ads/admin — create ad
router.post(
'/',
validate(createAdSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const ad = await galleryAdsService.create(req.body);
res.status(201).json(ad);
} catch (err) {
next(err);
}
}
);
// PUT /api/gallery-ads/admin/reorder — bulk reorder
router.put(
'/reorder',
validate(reorderAdsSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
await galleryAdsService.reorder(req.body.ids);
res.json({ success: true });
} catch (err) {
next(err);
}
}
);
// PUT /api/gallery-ads/admin/:id — update ad
router.put(
'/:id',
validate(updateAdSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = parseInt(req.params.id as string, 10);
const ad = await galleryAdsService.update(id, req.body);
if (!ad) {
res.status(404).json({ error: 'Ad not found' });
return;
}
res.json(ad);
} catch (err) {
if (err instanceof Error && err.message.includes('Cannot change type')) {
res.status(400).json({ error: err.message });
return;
}
next(err);
}
}
);
// DELETE /api/gallery-ads/admin/:id — delete ad
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = parseInt(req.params.id as string, 10);
await galleryAdsService.delete(id);
res.json({ success: true });
} catch (err) {
if (err instanceof Error && err.message.includes('Cannot delete')) {
res.status(400).json({ error: err.message });
return;
}
next(err);
}
});
export { router as galleryAdsAdminRouter };

View File

@@ -0,0 +1,64 @@
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 { validate } from '../../middleware/validate';
import { optionalAuth } from '../../middleware/auth.middleware';
import { adTrackingRateLimit } from '../../middleware/rate-limit';
const router = Router();
// GET /api/gallery-ads — get active ads for current viewer
router.get('/', optionalAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const isAuthenticated = !!req.user;
let hasActiveSubscription = false;
if (req.user) {
const sub = await prisma.userSubscription.findFirst({
where: {
userId: req.user.id,
status: 'active',
endDate: { gte: new Date() },
},
});
hasActiveSubscription = !!sub;
}
const ads = await galleryAdsService.getActiveAds({
isAuthenticated,
hasActiveSubscription,
});
res.json(ads);
} catch (err) {
next(err);
}
});
// POST /api/gallery-ads/track — record impression or click
router.post(
'/track',
adTrackingRateLimit,
optionalAuth,
validate(trackAdSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { adId, event, sessionId } = req.body;
const userId = req.user?.id;
if (event === 'impression') {
await galleryAdsService.recordImpression(adId, sessionId, userId);
} else {
await galleryAdsService.recordClick(adId, sessionId, userId);
}
res.json({ success: true });
} catch {
// Silent fail for tracking — non-critical
res.json({ success: true });
}
}
);
export { router as galleryAdsPublicRouter };

View File

@@ -0,0 +1,48 @@
import { z } from 'zod';
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'),
title: z.string().min(1).max(200),
subtitle: z.string().max(500).optional().nullable(),
imagePath: z.string().max(500).optional().nullable(),
linkUrl: z.string().max(500).optional().nullable(),
ctaText: z.string().max(100).optional().nullable(),
ctaStyle: z.enum(['primary', 'outline', 'link']).optional().default('primary'),
bgColor: z.string().max(20).optional().nullable(),
iconEmoji: z.string().max(10).optional().nullable(),
visibility: z.enum(['everyone', 'anonymous', 'authenticated', 'non_subscriber']).optional().default('everyone'),
isActive: z.boolean().optional().default(false),
position: z.number().int().min(0).optional().default(0),
frequency: z.number().int().min(1).max(24).optional().default(6),
startDate: z.string().datetime().optional().nullable(),
endDate: z.string().datetime().optional().nullable(),
});
export const updateAdSchema = createAdSchema.partial();
export const listAdsSchema = z.object({
page: z.coerce.number().int().min(1).optional().default(1),
limit: z.coerce.number().int().min(1).max(100).optional().default(50),
type: z.enum(['system', 'payment_subscribe', 'payment_donate', 'payment_shop', 'custom']).optional(),
isActive: z.enum(['true', 'false']).optional(),
});
export const reorderAdsSchema = z.object({
ids: z.array(z.number().int()).min(1),
});
export const trackAdSchema = z.object({
adId: z.number().int(),
event: z.enum(['impression', 'click']),
sessionId: z.string().uuid().optional(),
});
export const adAnalyticsQuerySchema = z.object({
days: z.coerce.number().int().min(1).max(365).optional().default(30),
});
export type CreateAdInput = z.infer<typeof createAdSchema>;
export type UpdateAdInput = z.infer<typeof updateAdSchema>;
export type ListAdsInput = z.infer<typeof listAdsSchema>;
export type TrackAdInput = z.infer<typeof trackAdSchema>;

View File

@@ -0,0 +1,298 @@
import { prisma } from '../../config/database';
import type { Prisma } from '@prisma/client';
import type { CreateAdInput, UpdateAdInput, ListAdsInput } from './gallery-ads.schemas';
interface ActiveAdsContext {
isAuthenticated: boolean;
hasActiveSubscription: boolean;
}
class GalleryAdsService {
/** Admin: paginated list of all ads */
async listAll(filters: ListAdsInput) {
const { page, limit, type, isActive } = filters;
const where: Prisma.AdWhereInput = {};
if (type) where.type = type;
if (isActive !== undefined) where.isActive = isActive === 'true';
const [ads, total] = await Promise.all([
prisma.ad.findMany({
where,
orderBy: [{ position: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * limit,
take: limit,
}),
prisma.ad.count({ where }),
]);
return {
ads,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}
/** Admin: get single ad */
async getById(id: number) {
return prisma.ad.findUnique({ where: { id } });
}
/** Admin: create a new ad */
async create(data: CreateAdInput) {
return prisma.ad.create({
data: {
type: data.type,
variant: data.variant,
title: data.title,
subtitle: data.subtitle ?? null,
imagePath: data.imagePath ?? null,
linkUrl: data.linkUrl ?? null,
ctaText: data.ctaText ?? null,
ctaStyle: data.ctaStyle,
bgColor: data.bgColor ?? null,
iconEmoji: data.iconEmoji ?? null,
visibility: data.visibility,
isActive: data.isActive,
position: data.position,
frequency: data.frequency,
startDate: data.startDate ? new Date(data.startDate) : null,
endDate: data.endDate ? new Date(data.endDate) : null,
isSystemAd: false,
},
});
}
/** Admin: update an ad */
async update(id: number, data: UpdateAdInput) {
const existing = await prisma.ad.findUnique({ where: { id } });
if (!existing) return null;
// Block type change on system ads
if (existing.isSystemAd && data.type && data.type !== existing.type) {
throw new Error('Cannot change type of a system ad');
}
const updateData: Prisma.AdUncheckedUpdateInput = {};
if (data.type !== undefined) updateData.type = data.type;
if (data.variant !== undefined) updateData.variant = data.variant;
if (data.title !== undefined) updateData.title = data.title;
if (data.subtitle !== undefined) updateData.subtitle = data.subtitle;
if (data.imagePath !== undefined) updateData.imagePath = data.imagePath;
if (data.linkUrl !== undefined) updateData.linkUrl = data.linkUrl;
if (data.ctaText !== undefined) updateData.ctaText = data.ctaText;
if (data.ctaStyle !== undefined) updateData.ctaStyle = data.ctaStyle;
if (data.bgColor !== undefined) updateData.bgColor = data.bgColor;
if (data.iconEmoji !== undefined) updateData.iconEmoji = data.iconEmoji;
if (data.visibility !== undefined) updateData.visibility = data.visibility;
if (data.isActive !== undefined) updateData.isActive = data.isActive;
if (data.position !== undefined) updateData.position = data.position;
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;
updateData.updatedAt = new Date();
return prisma.ad.update({ where: { id }, data: updateData });
}
/** Admin: delete an ad (blocked for system ads) */
async delete(id: number) {
const existing = await prisma.ad.findUnique({ where: { id } });
if (!existing) return null;
if (existing.isSystemAd) {
throw new Error('Cannot delete a system ad');
}
return prisma.ad.delete({ where: { id } });
}
/** Admin: bulk reorder ads by position */
async reorder(ids: number[]) {
const ops = ids.map((id, index) =>
prisma.ad.update({
where: { id },
data: { position: index, updatedAt: new Date() },
})
);
await prisma.$transaction(ops);
}
/** Public: get active ads filtered by visibility, schedule, and feature gates */
async getActiveAds(context: ActiveAdsContext) {
const now = new Date();
// Check if gallery ads feature is enabled
const settings = await prisma.siteSettings.findFirst();
if (!settings?.enableGalleryAds) return [];
const ads = await prisma.ad.findMany({
where: {
isActive: true,
OR: [
{ startDate: null },
{ startDate: { lte: now } },
],
},
orderBy: { position: 'asc' },
});
// Filter in application layer for complex logic
return ads.filter((ad) => {
// End date check
if (ad.endDate && ad.endDate < now) return false;
// Visibility check
switch (ad.visibility) {
case 'anonymous':
if (context.isAuthenticated) return false;
break;
case 'authenticated':
if (!context.isAuthenticated) return false;
break;
case 'non_subscriber':
if (!context.isAuthenticated || context.hasActiveSubscription) return false;
break;
// 'everyone' always passes
}
// Payment-type ads only if payments enabled
if (['payment_subscribe', 'payment_donate', 'payment_shop'].includes(ad.type)) {
if (!settings.enablePayments) return false;
}
return true;
});
}
/**
* Ensure a Session record exists for FK integrity.
* Follows the same upsert pattern used by media upvote/comment routes.
*/
private async ensureSession(sessionId: string): Promise<void> {
await prisma.session.upsert({
where: { id: sessionId },
create: { id: sessionId },
update: { lastSeenAt: new Date() },
});
}
/** Public: increment impression count + create individual record */
async recordImpression(adId: number, sessionId?: string, userId?: string) {
if (sessionId) {
await this.ensureSession(sessionId);
}
await prisma.$transaction([
prisma.ad.update({
where: { id: adId },
data: { impressionCount: { increment: 1 } },
}),
prisma.adImpression.create({
data: {
adId,
sessionId: sessionId ?? null,
userId: userId ?? null,
},
}),
]);
}
/** Public: increment click count + create individual record */
async recordClick(adId: number, sessionId?: string, userId?: string) {
if (sessionId) {
await this.ensureSession(sessionId);
}
await prisma.$transaction([
prisma.ad.update({
where: { id: adId },
data: { clickCount: { increment: 1 } },
}),
prisma.adClick.create({
data: {
adId,
sessionId: sessionId ?? null,
userId: userId ?? null,
},
}),
]);
}
/**
* Admin: get per-ad analytics with daily breakdown.
* Counter fields on Ad remain for fast table reads; these individual records
* enable time-series queries. For high-traffic galleries, consider periodic
* cleanup of records older than 90 days (preserving counter totals).
*/
async getAdAnalytics(adId: number, days: number = 30) {
const since = new Date(Date.now() - days * 86400000);
const [dailyImpressions, dailyClicks, uniqueSessions, ad] = await Promise.all([
prisma.$queryRaw<{ date: string; count: bigint }[]>`
SELECT DATE("created_at") as date, COUNT(*) as count
FROM ad_impressions
WHERE ad_id = ${adId} AND 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 ad_id = ${adId} AND created_at >= ${since}
GROUP BY DATE("created_at")
ORDER BY date
`,
prisma.adImpression.findMany({
where: { adId, createdAt: { gte: since }, sessionId: { not: null } },
distinct: ['sessionId'],
select: { sessionId: true },
}),
prisma.ad.findUnique({
where: { id: adId },
select: { impressionCount: true, clickCount: true },
}),
]);
// 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));
const totalImpressions = ad?.impressionCount ?? 0;
const totalClicks = ad?.clickCount ?? 0;
const ctr = totalImpressions > 0 ? Number(((totalClicks / totalImpressions) * 100).toFixed(1)) : 0;
return {
daily,
totals: {
impressions: totalImpressions,
clicks: totalClicks,
uniqueSessions: uniqueSessions.length,
ctr,
},
};
}
}
export const galleryAdsService = new GalleryAdsService();

View File

@@ -0,0 +1,90 @@
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 { effectivenessService } from './effectiveness.service';
import {
effectivenessQuerySchema,
trendQuerySchema,
geoQuerySchema,
repQuerySchema,
} from './effectiveness.schemas';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN];
const router = Router();
router.use(authenticate);
router.use(requireRole(...ADMIN_ROLES));
// GET /api/influence/effectiveness/overview
router.get(
'/overview',
validate(effectivenessQuerySchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = await effectivenessService.getOverviewStats(req.query as any);
res.json(data);
} catch (err) {
next(err);
}
},
);
// GET /api/influence/effectiveness/representatives
router.get(
'/representatives',
validate(repQuerySchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = await effectivenessService.getRepresentativeEffectiveness(req.query as any);
res.json(data);
} catch (err) {
next(err);
}
},
);
// GET /api/influence/effectiveness/geographic
router.get(
'/geographic',
validate(geoQuerySchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = await effectivenessService.getGeographicBreakdown(req.query as any);
res.json(data);
} catch (err) {
next(err);
}
},
);
// GET /api/influence/effectiveness/funnel
router.get(
'/funnel',
validate(effectivenessQuerySchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = await effectivenessService.getFunnelData(req.query as any);
res.json(data);
} catch (err) {
next(err);
}
},
);
// GET /api/influence/effectiveness/trends
router.get(
'/trends',
validate(trendQuerySchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = await effectivenessService.getActivityTrends(req.query as any);
res.json(data);
} catch (err) {
next(err);
}
},
);
export { router as effectivenessRouter };

View File

@@ -0,0 +1,26 @@
import { z } from 'zod';
export const effectivenessQuerySchema = z.object({
campaignId: z.string().optional(),
dateFrom: z.string().datetime({ offset: true }).optional(),
dateTo: z.string().datetime({ offset: true }).optional(),
});
export const trendQuerySchema = effectivenessQuerySchema.extend({
granularity: z.enum(['day', 'week']).default('day'),
});
export const geoQuerySchema = effectivenessQuerySchema.extend({
groupBy: z.enum(['province', 'city', 'postalCode']).default('postalCode'),
limit: z.coerce.number().int().positive().max(200).default(20),
});
export const repQuerySchema = effectivenessQuerySchema.extend({
sortBy: z.enum(['responseCount', 'responseRate', 'name']).default('responseCount'),
limit: z.coerce.number().int().positive().max(200).default(20),
});
export type EffectivenessQuery = z.infer<typeof effectivenessQuerySchema>;
export type TrendQuery = z.infer<typeof trendQuerySchema>;
export type GeoQuery = z.infer<typeof geoQuerySchema>;
export type RepQuery = z.infer<typeof repQuerySchema>;

View File

@@ -0,0 +1,459 @@
import { Prisma, ResponseStatus } from '@prisma/client';
import { prisma } from '../../../config/database';
import type { EffectivenessQuery, TrendQuery, GeoQuery, RepQuery } from './effectiveness.schemas';
function buildDateFilter(query: EffectivenessQuery) {
const filter: { gte?: Date; lte?: Date } = {};
if (query.dateFrom) filter.gte = new Date(query.dateFrom);
if (query.dateTo) filter.lte = new Date(query.dateTo);
return Object.keys(filter).length > 0 ? filter : undefined;
}
export const effectivenessService = {
/**
* Per-campaign KPIs: email counts, response counts, response rate
*/
async getOverviewStats(query: EffectivenessQuery) {
const dateFilter = buildDateFilter(query);
const campaignWhere: Prisma.CampaignWhereInput = {};
if (query.campaignId) campaignWhere.id = query.campaignId;
const emailWhere: Prisma.CampaignEmailWhereInput = {};
if (query.campaignId) emailWhere.campaignId = query.campaignId;
if (dateFilter) emailWhere.sentAt = dateFilter;
const responseWhere: Prisma.RepresentativeResponseWhereInput = {};
if (query.campaignId) responseWhere.campaignId = query.campaignId;
if (dateFilter) responseWhere.createdAt = dateFilter;
const callWhere: Prisma.CallWhereInput = {};
if (query.campaignId) callWhere.campaignId = query.campaignId;
if (dateFilter) callWhere.calledAt = dateFilter;
const [campaigns, emailsByStatus, responsesByStatus, totalCalls, totalEmails, totalResponses] = await Promise.all([
prisma.campaign.findMany({
where: campaignWhere,
select: {
id: true,
title: true,
slug: true,
status: true,
createdAt: true,
_count: { select: { emails: true, responses: true, calls: true } },
},
orderBy: { createdAt: 'desc' },
}),
prisma.campaignEmail.groupBy({
by: ['campaignId', 'status'],
where: emailWhere,
_count: true,
}),
prisma.representativeResponse.groupBy({
by: ['campaignId', 'status'],
where: responseWhere,
_count: true,
}),
prisma.call.count({ where: callWhere }),
prisma.campaignEmail.count({ where: emailWhere }),
prisma.representativeResponse.count({ where: { ...responseWhere, status: ResponseStatus.APPROVED } }),
]);
// Build per-campaign email status map
const emailStatusMap = new Map<string, Record<string, number>>();
for (const row of emailsByStatus) {
if (!emailStatusMap.has(row.campaignId)) {
emailStatusMap.set(row.campaignId, {});
}
emailStatusMap.get(row.campaignId)![row.status] = row._count;
}
// Build per-campaign response status map
const responseStatusMap = new Map<string, Record<string, number>>();
for (const row of responsesByStatus) {
if (!responseStatusMap.has(row.campaignId)) {
responseStatusMap.set(row.campaignId, {});
}
responseStatusMap.get(row.campaignId)![row.status] = row._count;
}
const campaignStats = campaigns.map((c) => {
const emailBreakdown = emailStatusMap.get(c.id) || {};
const responseBreakdown = responseStatusMap.get(c.id) || {};
const emailTotal = Object.values(emailBreakdown).reduce((a, b) => a + b, 0);
const approvedResponses = responseBreakdown[ResponseStatus.APPROVED] || 0;
const responseRate = emailTotal > 0 ? approvedResponses / emailTotal : 0;
return {
campaignId: c.id,
title: c.title,
slug: c.slug,
status: c.status,
createdAt: c.createdAt,
emailTotal,
emailBreakdown,
responseTotal: Object.values(responseBreakdown).reduce((a, b) => a + b, 0),
approvedResponses,
responseBreakdown,
responseRate,
callCount: c._count.calls,
};
});
const activeCampaigns = campaigns.filter((c) => c.status === 'ACTIVE').length;
const avgResponseRate = totalEmails > 0 ? totalResponses / totalEmails : 0;
return {
summary: {
totalEmails,
totalResponses,
totalCalls,
activeCampaigns,
totalCampaigns: campaigns.length,
avgResponseRate,
},
campaigns: campaignStats,
};
},
/**
* Cross-campaign representative tracking: emails received, responses given, response rate
*/
async getRepresentativeEffectiveness(query: RepQuery) {
const dateFilter = buildDateFilter(query);
const emailWhere: Prisma.CampaignEmailWhereInput = {};
if (query.campaignId) emailWhere.campaignId = query.campaignId;
if (dateFilter) emailWhere.sentAt = dateFilter;
const responseWhere: Prisma.RepresentativeResponseWhereInput = {};
if (query.campaignId) responseWhere.campaignId = query.campaignId;
if (dateFilter) responseWhere.createdAt = dateFilter;
const [emailsByRecipient, responsesByRep] = await Promise.all([
prisma.campaignEmail.groupBy({
by: ['recipientEmail', 'recipientName', 'recipientLevel'],
where: emailWhere,
_count: true,
}),
prisma.representativeResponse.groupBy({
by: ['representativeName', 'representativeLevel'],
where: { ...responseWhere, status: ResponseStatus.APPROVED },
_count: true,
}),
]);
// Also count verified responses
const verifiedByRep = await prisma.representativeResponse.groupBy({
by: ['representativeName'],
where: { ...responseWhere, isVerified: true },
_count: true,
});
const verifiedMap = new Map(verifiedByRep.map((r) => [r.representativeName, r._count]));
// Build response map keyed by rep name (normalized lowercase)
const responseMap = new Map<string, { count: number; level: string }>();
for (const row of responsesByRep) {
responseMap.set(row.representativeName.toLowerCase(), {
count: row._count,
level: row.representativeLevel,
});
}
// Merge: start from email recipients, enrich with response data
const repMap = new Map<string, {
name: string;
email: string;
level: string | null;
emailsReceived: number;
responsesGiven: number;
verifiedCount: number;
responseRate: number;
}>();
for (const row of emailsByRecipient) {
const key = (row.recipientName || row.recipientEmail).toLowerCase();
const existing = repMap.get(key);
if (existing) {
existing.emailsReceived += row._count;
} else {
const respData = responseMap.get(key);
const verifiedCount = verifiedMap.get(row.recipientName || row.recipientEmail) || 0;
repMap.set(key, {
name: row.recipientName || row.recipientEmail,
email: row.recipientEmail,
level: row.recipientLevel,
emailsReceived: row._count,
responsesGiven: respData?.count || 0,
verifiedCount,
responseRate: 0,
});
}
}
// Also add reps who responded but weren't in email records
for (const row of responsesByRep) {
const key = row.representativeName.toLowerCase();
if (!repMap.has(key)) {
const verifiedCount = verifiedMap.get(row.representativeName) || 0;
repMap.set(key, {
name: row.representativeName,
email: '',
level: row.representativeLevel,
emailsReceived: 0,
responsesGiven: row._count,
verifiedCount,
responseRate: 0,
});
}
}
// Compute response rates
const reps = Array.from(repMap.values()).map((r) => ({
...r,
responseRate: r.emailsReceived > 0 ? r.responsesGiven / r.emailsReceived : 0,
}));
// Sort
if (query.sortBy === 'responseRate') {
reps.sort((a, b) => b.responseRate - a.responseRate);
} else if (query.sortBy === 'name') {
reps.sort((a, b) => a.name.localeCompare(b.name));
} else {
reps.sort((a, b) => b.responsesGiven - a.responsesGiven);
}
// Level distribution
const levelCounts: Record<string, number> = {};
for (const row of responsesByRep) {
levelCounts[row.representativeLevel] = (levelCounts[row.representativeLevel] || 0) + row._count;
}
return {
representatives: reps.slice(0, query.limit),
totalRepresentatives: reps.length,
levelDistribution: Object.entries(levelCounts).map(([level, count]) => ({ level, count })),
};
},
/**
* Engagement breakdown by geographic area (postal code, city, or province)
*/
async getGeographicBreakdown(query: GeoQuery) {
const dateFilter = buildDateFilter(query);
const emailWhere: Prisma.CampaignEmailWhereInput = {
userPostalCode: { not: null },
};
if (query.campaignId) emailWhere.campaignId = query.campaignId;
if (dateFilter) emailWhere.sentAt = dateFilter;
if (query.groupBy === 'postalCode') {
const results = await prisma.campaignEmail.groupBy({
by: ['userPostalCode'],
where: emailWhere,
_count: true,
orderBy: { _count: { userPostalCode: 'desc' } },
take: query.limit,
});
// Enrich with city/province from postal code cache
const postalCodes = results
.map((r) => r.userPostalCode)
.filter((pc): pc is string => pc !== null);
const cacheEntries = postalCodes.length > 0
? await prisma.postalCodeCache.findMany({
where: { postalCode: { in: postalCodes } },
select: { postalCode: true, city: true, province: true },
})
: [];
const cacheMap = new Map(cacheEntries.map((e) => [e.postalCode, e]));
return {
groupBy: query.groupBy,
data: results.map((r) => {
const cache = cacheMap.get(r.userPostalCode || '');
return {
key: r.userPostalCode || 'Unknown',
emailCount: r._count,
city: cache?.city || null,
province: cache?.province || null,
};
}),
};
}
// For city/province grouping, we need to join with postal_code_cache
const groupCol = query.groupBy === 'province' ? 'pcc.province' : 'pcc.city';
const dateClause = dateFilter
? `AND ce."sentAt" ${dateFilter.gte ? `>= '${dateFilter.gte.toISOString()}'` : ''} ${dateFilter.lte ? `AND ce."sentAt" <= '${dateFilter.lte.toISOString()}'` : ''}`
: '';
const campaignClause = query.campaignId
? `AND ce."campaignId" = '${query.campaignId}'`
: '';
const rawResults = await prisma.$queryRawUnsafe<Array<{ key: string; email_count: bigint }>>(
`SELECT ${groupCol} as key, COUNT(*) as email_count
FROM campaign_emails ce
LEFT JOIN postal_code_cache pcc ON ce."userPostalCode" = pcc."postalCode"
WHERE ce."userPostalCode" IS NOT NULL
AND ${groupCol} IS NOT NULL
${campaignClause}
${dateClause}
GROUP BY ${groupCol}
ORDER BY email_count DESC
LIMIT $1`,
query.limit,
);
return {
groupBy: query.groupBy,
data: rawResults.map((r) => ({
key: r.key,
emailCount: Number(r.email_count),
city: null,
province: null,
})),
};
},
/**
* Conversion funnel: emails → unique participants → responses → verified responses
*/
async getFunnelData(query: EffectivenessQuery) {
const dateFilter = buildDateFilter(query);
const emailWhere: Prisma.CampaignEmailWhereInput = {};
if (query.campaignId) emailWhere.campaignId = query.campaignId;
if (dateFilter) emailWhere.sentAt = dateFilter;
const responseWhere: Prisma.RepresentativeResponseWhereInput = {};
if (query.campaignId) responseWhere.campaignId = query.campaignId;
if (dateFilter) responseWhere.createdAt = dateFilter;
const callWhere: Prisma.CallWhereInput = {};
if (query.campaignId) callWhere.campaignId = query.campaignId;
if (dateFilter) callWhere.calledAt = dateFilter;
// Build date clause for raw SQL
const dateClauseParts: string[] = [];
if (query.campaignId) dateClauseParts.push(`"campaignId" = '${query.campaignId}'`);
if (dateFilter?.gte) dateClauseParts.push(`"sentAt" >= '${dateFilter.gte.toISOString()}'`);
if (dateFilter?.lte) dateClauseParts.push(`"sentAt" <= '${dateFilter.lte.toISOString()}'`);
const rawWhereClause = dateClauseParts.length > 0
? `WHERE ${dateClauseParts.join(' AND ')}`
: '';
const [emailsSent, uniqueParticipants, approvedResponses, verifiedResponses, callsMade] = await Promise.all([
prisma.campaignEmail.count({ where: emailWhere }),
prisma.$queryRawUnsafe<[{ count: bigint }]>(
`SELECT COUNT(DISTINCT "userEmail") as count FROM campaign_emails ${rawWhereClause}`,
),
prisma.representativeResponse.count({
where: { ...responseWhere, status: ResponseStatus.APPROVED },
}),
prisma.representativeResponse.count({
where: { ...responseWhere, isVerified: true },
}),
prisma.call.count({ where: callWhere }),
]);
const participantCount = Number(uniqueParticipants[0]?.count || 0);
const stages = [
{ name: 'Emails Sent', count: emailsSent },
{ name: 'Unique Participants', count: participantCount },
{ name: 'Responses Received', count: approvedResponses },
{ name: 'Verified Responses', count: verifiedResponses },
{ name: 'Calls Made', count: callsMade },
];
// Compute percentages relative to first stage and dropoff from previous
const firstCount = stages[0].count || 1;
return stages.map((stage, i) => ({
...stage,
percentOfFirst: stage.count / firstCount,
dropoff: i > 0
? (stages[i - 1].count > 0
? 1 - stage.count / stages[i - 1].count
: 0)
: 0,
}));
},
/**
* Time-series: daily/weekly email + response volumes
*/
async getActivityTrends(query: TrendQuery) {
const dateFilter = buildDateFilter(query);
const truncFn = query.granularity === 'week' ? 'week' : 'day';
// Default: last 30 days
const defaultFrom = new Date();
defaultFrom.setDate(defaultFrom.getDate() - 30);
const from = dateFilter?.gte || defaultFrom;
const to = dateFilter?.lte || new Date();
const campaignClause = query.campaignId
? `AND "campaignId" = '${query.campaignId}'`
: '';
const [emailTrends, responseTrends] = await Promise.all([
prisma.$queryRawUnsafe<Array<{ period: Date; count: bigint }>>(
`SELECT DATE_TRUNC('${truncFn}', "sentAt") as period, COUNT(*) as count
FROM campaign_emails
WHERE "sentAt" >= $1 AND "sentAt" <= $2
${campaignClause}
GROUP BY period
ORDER BY period ASC`,
from,
to,
),
prisma.$queryRawUnsafe<Array<{ period: Date; count: bigint }>>(
`SELECT DATE_TRUNC('${truncFn}', "createdAt") as period, COUNT(*) as count
FROM representative_responses
WHERE "createdAt" >= $1 AND "createdAt" <= $2
AND status = 'APPROVED'
${campaignClause}
GROUP BY period
ORDER BY period ASC`,
from,
to,
),
]);
// Merge into a single series with both email and response counts
const periodMap = new Map<string, { emails: number; responses: number }>();
for (const row of emailTrends) {
const key = row.period.toISOString().split('T')[0];
if (!periodMap.has(key)) periodMap.set(key, { emails: 0, responses: 0 });
periodMap.get(key)!.emails = Number(row.count);
}
for (const row of responseTrends) {
const key = row.period.toISOString().split('T')[0];
if (!periodMap.has(key)) periodMap.set(key, { emails: 0, responses: 0 });
periodMap.get(key)!.responses = Number(row.count);
}
// Sort by date and return
const series = Array.from(periodMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, counts]) => ({
date,
emails: counts.emails,
responses: counts.responses,
}));
return {
granularity: query.granularity,
dateFrom: from.toISOString(),
dateTo: to.toISOString(),
series,
};
},
};

View File

@@ -13,6 +13,7 @@ export const updateMapSettingsSchema = z.object({
qrCode2Label: z.string().nullable().optional(),
qrCode3Url: z.string().url().nullable().optional().or(z.literal('')),
qrCode3Label: z.string().nullable().optional(),
publicMapEnabled: z.boolean().optional(),
});
export type UpdateMapSettingsInput = z.infer<typeof updateMapSettingsSchema>;

View File

@@ -78,6 +78,7 @@ export async function publicRoutes(fastify: FastifyInstance) {
publishedAt: true,
category: true,
isLocked: true,
accessLevel: true,
viewCount: true,
upvoteCount: true,
commentCount: true,
@@ -262,6 +263,7 @@ export async function publicRoutes(fastify: FastifyInstance) {
select: {
path: true,
filename: true,
accessLevel: true,
},
});
@@ -269,6 +271,40 @@ export async function publicRoutes(fastify: FastifyInstance) {
return reply.code(404).send({ message: 'Video not found or not published' });
}
// Content gating: check access level against user subscription
if (video.accessLevel && video.accessLevel !== 'free') {
const userId = (request as any).user?.id;
if (!userId) {
return reply.code(403).send({
message: 'This content requires a subscription',
accessLevel: video.accessLevel,
requiresAuth: true,
});
}
const subscription = await prisma.userSubscription.findFirst({
where: {
userId,
status: 'active',
},
include: { plan: true },
});
if (!subscription) {
return reply.code(403).send({
message: 'This content requires an active subscription',
accessLevel: video.accessLevel,
requiresSubscription: true,
});
}
// Premium content requires tier >= 2
if (video.accessLevel === 'premium' && (subscription.plan?.tier ?? 0) < 2) {
return reply.code(403).send({
message: 'This content requires a premium subscription',
accessLevel: video.accessLevel,
requiresUpgrade: true,
});
}
}
// Validate path doesn't contain traversal attempts
if (video.path.includes('..') || video.filename.includes('..')) {
logger.warn(`Path traversal attempt detected: ${video.path}/${video.filename}`);

View File

@@ -18,6 +18,7 @@ const UpdateVideoSchema = z.object({
quality: z.string().max(50).nullable().optional(),
position: z.number().int().min(0).nullable().optional(),
isShort: z.boolean().optional(),
accessLevel: z.enum(['free', 'member', 'premium']).optional(),
});
export async function videoActionsRoutes(fastify: FastifyInstance) {
@@ -55,6 +56,7 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
if (updates.quality !== undefined) data.quality = updates.quality;
if (updates.position !== undefined) data.position = updates.position;
if (updates.isShort !== undefined) data.isShort = updates.isShort;
if (updates.accessLevel !== undefined) data.accessLevel = updates.accessLevel;
const updatedVideo = await prisma.video.update({
where: { id: videoId },
@@ -388,4 +390,47 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
}
}
);
/**
* POST /videos/bulk-access-level
* Set access level on multiple videos at once
*/
fastify.post<{
Body: { videoIds: number[]; accessLevel: string };
}>(
'/bulk-access-level',
{
preHandler: requireAdminRole,
},
async (request, reply) => {
const schema = z.object({
videoIds: z.array(z.number().int()).min(1).max(500),
accessLevel: z.enum(['free', 'member', 'premium']),
});
const parseResult = schema.safeParse(request.body);
if (!parseResult.success) {
return reply.code(400).send({ message: 'Invalid input', errors: parseResult.error.errors });
}
const { videoIds, accessLevel } = parseResult.data;
try {
const result = await prisma.video.updateMany({
where: { id: { in: videoIds } },
data: { accessLevel },
});
logger.info(`Bulk updated access level to "${accessLevel}" for ${result.count} videos`, { videoIds });
return {
success: true,
updatedCount: result.count,
};
} catch (error) {
logger.error('Failed to bulk update access level', { error, videoIds });
return reply.code(500).send({ message: 'Failed to update access levels' });
}
}
);
}

View File

@@ -77,6 +77,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
scheduledUnpublishAt: true,
category: true,
isShort: true,
accessLevel: true,
},
orderBy: {
createdAt: 'desc',

View File

@@ -216,6 +216,12 @@ async function exportToMkDocs(opts: ExportOptions): Promise<string> {
content = content.replace(/href="\/gallery\?expanded=/g, `href="${adminUrl}/gallery?expanded=`);
content = content.replace(/src="http:\/\/localhost:4100\//g, `src="${adminUrl.replace(/:\d+$/, ':4100')}/`);
// Rewrite payment page URLs to absolute for MkDocs context
content = content.replace(/href="\/donate"/g, `href="${adminUrl}/donate"`);
content = content.replace(/href="\/pricing"/g, `href="${adminUrl}/pricing"`);
content = content.replace(/href="\/shop"/g, `href="${adminUrl}/shop"`);
content = content.replace(/href="\/payments\/success"/g, `href="${adminUrl}/payments/success"`);
await fs.writeFile(filePath, content, 'utf-8');
logger.info(`Exported landing page to MkDocs: ${mkdocsPath} (${editorMode}/${exportMode})`);

View File

@@ -0,0 +1,180 @@
import { prisma } from '../../config/database';
import { getStripe } from '../../services/stripe.client';
import { env } from '../../config/env';
import { paymentSettingsService } from './payment-settings.service';
import { stringify } from 'csv-stringify/sync';
import { logger } from '../../utils/logger';
export const donationsService = {
/** Create a Stripe Checkout session for a donation */
async createDonationCheckout(
amountCents: number,
email: string,
name?: string,
message?: string,
isAnonymous?: boolean,
) {
const settings = await paymentSettingsService.get();
if (!settings.enableDonations) throw new Error('Donations are currently disabled');
if (amountCents < settings.donationMinimum) {
throw new Error(`Minimum donation is $${(settings.donationMinimum / 100).toFixed(2)}`);
}
const stripe = await getStripe();
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',
},
unit_amount: amountCents,
},
quantity: 1,
}],
customer_email: email,
success_url: `${env.ADMIN_URL}/payments/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.ADMIN_URL}/donate`,
metadata: {
type: 'donation',
email,
name: name || '',
message: message || '',
isAnonymous: isAnonymous ? 'true' : 'false',
},
});
// Create pending order
await prisma.order.create({
data: {
amountCAD: amountCents,
status: 'PENDING',
stripeCheckoutSessionId: session.id,
type: 'donation',
buyerEmail: email,
buyerName: name || null,
donorMessage: message || null,
isAnonymous: isAnonymous || false,
},
});
return { sessionId: session.id, url: session.url };
},
/** List donations (admin) */
async listDonations(filters: { page: number; limit: number; search?: string }) {
const { page, limit, search } = filters;
const where: Record<string, unknown> = { type: 'donation' };
if (search) {
(where as Record<string, unknown>).OR = [
{ buyerEmail: { contains: search, mode: 'insensitive' } },
{ buyerName: { contains: search, mode: 'insensitive' } },
];
}
const [orders, total] = await Promise.all([
prisma.order.findMany({
where: where as import('@prisma/client').Prisma.OrderWhereInput,
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.order.count({ where: where as import('@prisma/client').Prisma.OrderWhereInput }),
]);
return {
donations: orders,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
/** Refund a donation via Stripe */
async refundDonation(orderId: string, reason?: string) {
const order = await prisma.order.findUnique({ where: { id: orderId } });
if (!order) throw new Error('Donation not found');
if (order.type !== 'donation') throw new Error('Order is not a donation');
if (order.status !== 'COMPLETED') throw new Error('Only completed donations can be refunded');
if (!order.stripePaymentIntentId) throw new Error('No Stripe payment intent found for this donation');
const stripe = await getStripe();
await stripe.refunds.create({
payment_intent: order.stripePaymentIntentId,
reason: 'requested_by_customer',
metadata: {
admin_reason: reason || 'Admin-initiated refund',
order_id: orderId,
},
});
const updated = await prisma.order.update({
where: { id: orderId },
data: { status: 'REFUNDED' },
});
logger.info(`Donation refunded: ${orderId}, $${(order.amountCAD / 100).toFixed(2)}`, {
orderId,
reason: reason || 'No reason provided',
});
return updated;
},
/** Export donations to CSV */
async exportToCsv(filters: { search?: string; status?: string }) {
const where: Record<string, unknown> = { type: 'donation' };
if (filters.status) {
(where as Record<string, unknown>).status = filters.status;
}
if (filters.search) {
(where as Record<string, unknown>).OR = [
{ buyerEmail: { contains: filters.search, mode: 'insensitive' } },
{ buyerName: { contains: filters.search, mode: 'insensitive' } },
];
}
const orders = await prisma.order.findMany({
where: where as import('@prisma/client').Prisma.OrderWhereInput,
orderBy: { createdAt: 'desc' },
});
return stringify(orders.map((o) => ({
'Date': o.createdAt.toISOString(),
'Donor Name': o.isAnonymous ? 'Anonymous' : (o.buyerName || ''),
'Donor Email': o.isAnonymous ? '' : (o.buyerEmail || ''),
'Amount (CAD)': (o.amountCAD / 100).toFixed(2),
'Status': o.status,
'Message': o.donorMessage || '',
'Anonymous': o.isAnonymous ? 'Yes' : 'No',
'Stripe Payment Intent': o.stripePaymentIntentId || '',
'Stripe Checkout Session': o.stripeCheckoutSessionId || '',
'Completed At': o.completedAt ? o.completedAt.toISOString() : '',
'Order ID': o.id,
})), { header: true });
},
/** Get donation stats */
async getDonationStats() {
const [totalDonations, totalAmount, recentDonations] = await Promise.all([
prisma.order.count({ where: { type: 'donation', status: 'COMPLETED' } }),
prisma.order.aggregate({
where: { type: 'donation', status: 'COMPLETED' },
_sum: { amountCAD: true },
}),
prisma.order.findMany({
where: { type: 'donation', status: 'COMPLETED' },
orderBy: { createdAt: 'desc' },
take: 5,
}),
]);
return {
totalDonations,
totalAmount: totalAmount._sum.amountCAD || 0,
recentDonations,
};
},
};

View File

@@ -0,0 +1,168 @@
import { emailService } from '../../services/email.service';
import { siteSettingsService } from '../settings/settings.service';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
export const paymentEmailService = {
/** Send donation receipt after successful checkout */
async sendDonationReceipt(order: {
id: string;
buyerEmail: string;
buyerName: string | null;
amountCAD: number;
donorMessage: string | null;
isAnonymous: boolean;
completedAt: Date | null;
}): Promise<void> {
try {
const orgName = await this.getOrgName();
const vars: Record<string, string> = {
RECIPIENT_NAME: order.buyerName || 'Supporter',
AMOUNT: `$${(order.amountCAD / 100).toFixed(2)}`,
ORDER_ID: order.id,
DONATION_DATE: (order.completedAt || new Date()).toLocaleDateString('en-CA', {
year: 'numeric', month: 'long', day: 'numeric',
}),
DONOR_MESSAGE: order.donorMessage || '',
IS_ANONYMOUS: order.isAnonymous ? 'true' : '',
ORGANIZATION_NAME: orgName,
};
const dbTemplate = await emailService['loadTemplateFromDatabase']('donation-receipt');
let html: string, text: string, subject: string;
if (dbTemplate) {
html = await emailService.processTemplate(dbTemplate.html, vars);
text = await emailService.processTextTemplate(dbTemplate.text, vars);
subject = emailService.processSubject(dbTemplate.subject, vars);
} else {
const htmlTemplate = emailService.loadTemplate('donation-receipt', 'html');
const txtTemplate = emailService.loadTemplate('donation-receipt', 'txt');
html = await emailService.processTemplate(htmlTemplate, vars);
text = await emailService.processTextTemplate(txtTemplate, vars);
subject = `Donation Receipt — ${orgName}`;
}
await emailService.sendEmail({ to: order.buyerEmail, subject, html, text });
logger.info(`Donation receipt sent to ${order.buyerEmail} for order ${order.id}`);
} catch (err) {
logger.error('Failed to send donation receipt email:', err);
}
},
/** Send product purchase receipt after successful checkout */
async sendProductReceipt(order: {
id: string;
buyerEmail: string;
buyerName: string | null;
amountCAD: number;
completedAt: Date | null;
product: { title: string; type: string } | null;
}): Promise<void> {
try {
const orgName = await this.getOrgName();
const vars: Record<string, string> = {
RECIPIENT_NAME: order.buyerName || 'Customer',
AMOUNT: `$${(order.amountCAD / 100).toFixed(2)}`,
ORDER_ID: order.id,
PRODUCT_TITLE: order.product?.title || 'Product',
PRODUCT_TYPE: order.product?.type || 'DIGITAL',
PURCHASE_DATE: (order.completedAt || new Date()).toLocaleDateString('en-CA', {
year: 'numeric', month: 'long', day: 'numeric',
}),
ORGANIZATION_NAME: orgName,
};
const dbTemplate = await emailService['loadTemplateFromDatabase']('product-receipt');
let html: string, text: string, subject: string;
if (dbTemplate) {
html = await emailService.processTemplate(dbTemplate.html, vars);
text = await emailService.processTextTemplate(dbTemplate.text, vars);
subject = emailService.processSubject(dbTemplate.subject, vars);
} else {
const htmlTemplate = emailService.loadTemplate('product-receipt', 'html');
const txtTemplate = emailService.loadTemplate('product-receipt', 'txt');
html = await emailService.processTemplate(htmlTemplate, vars);
text = await emailService.processTextTemplate(txtTemplate, vars);
subject = `Purchase Receipt — ${orgName}`;
}
await emailService.sendEmail({ to: order.buyerEmail, subject, html, text });
logger.info(`Product receipt sent to ${order.buyerEmail} for order ${order.id}`);
} catch (err) {
logger.error('Failed to send product receipt email:', err);
}
},
/** Send subscription welcome email after checkout */
async sendSubscriptionWelcome(opts: {
userId: string;
planId: number;
stripeSubscriptionId: string;
currentPeriodEnd: Date;
}): Promise<void> {
try {
const { prisma } = await import('../../config/database');
const [user, plan] = await Promise.all([
prisma.user.findUnique({ where: { id: opts.userId } }),
prisma.subscriptionPlan.findUnique({ where: { id: opts.planId } }),
]);
if (!user || !plan) {
logger.warn('Cannot send subscription welcome: user or plan not found', {
userId: opts.userId, planId: opts.planId,
});
return;
}
const orgName = await this.getOrgName();
const loginUrl = `${env.ADMIN_URL || 'http://localhost:3000'}/login`;
const frequency = plan.yearlyPriceCAD && plan.yearlyPriceCAD > 0
? 'per month or per year'
: 'per month';
const vars: Record<string, string> = {
RECIPIENT_NAME: user.name || user.email,
PLAN_NAME: plan.name,
AMOUNT: `$${(plan.priceCAD / 100).toFixed(2)}`,
FREQUENCY: frequency,
RENEWAL_DATE: opts.currentPeriodEnd.toLocaleDateString('en-CA', {
year: 'numeric', month: 'long', day: 'numeric',
}),
SUBSCRIPTION_ID: opts.stripeSubscriptionId,
LOGIN_URL: loginUrl,
ORGANIZATION_NAME: orgName,
};
const dbTemplate = await emailService['loadTemplateFromDatabase']('subscription-welcome');
let html: string, text: string, subject: string;
if (dbTemplate) {
html = await emailService.processTemplate(dbTemplate.html, vars);
text = await emailService.processTextTemplate(dbTemplate.text, vars);
subject = emailService.processSubject(dbTemplate.subject, vars);
} else {
const htmlTemplate = emailService.loadTemplate('subscription-welcome', 'html');
const txtTemplate = emailService.loadTemplate('subscription-welcome', 'txt');
html = await emailService.processTemplate(htmlTemplate, vars);
text = await emailService.processTextTemplate(txtTemplate, vars);
subject = `Welcome to ${plan.name}${orgName}`;
}
await emailService.sendEmail({ to: user.email, subject, html, text });
logger.info(`Subscription welcome sent to ${user.email} for plan ${plan.name}`);
} catch (err) {
logger.error('Failed to send subscription welcome email:', err);
}
},
async getOrgName(): Promise<string> {
try {
const settings = await siteSettingsService.get();
return settings.organizationName || 'Changemaker Lite';
} catch {
return 'Changemaker Lite';
}
},
};

View File

@@ -0,0 +1,71 @@
import { prisma } from '../../config/database';
import type { PaymentSettings } from '@prisma/client';
import type { UpdatePaymentSettingsInput } from './payments.schemas';
import { encrypt, decrypt } from '../../utils/crypto';
import { resetStripeClient } from '../../services/stripe.client';
const ENCRYPTED_FIELDS = ['stripeSecretKey', 'stripeWebhookSecret'] as const;
const SENSITIVE_FIELDS = ['stripeSecretKey', 'stripeWebhookSecret'] as const;
function decryptSettings(settings: PaymentSettings): PaymentSettings {
for (const field of ENCRYPTED_FIELDS) {
const value = settings[field];
if (typeof value === 'string' && value) {
(settings as Record<string, unknown>)[field] = decrypt(value);
}
}
return settings;
}
export const paymentSettingsService = {
/** Full settings with decrypted secrets (admin use) */
async get(): Promise<PaymentSettings> {
let settings = await prisma.paymentSettings.findFirst();
if (!settings) {
settings = await prisma.paymentSettings.create({ data: {} });
}
return decryptSettings(settings);
},
/** Public-safe settings (strips secret keys) */
async getPublic() {
const settings = await this.get();
const result = { ...settings } as Record<string, unknown>;
for (const field of SENSITIVE_FIELDS) {
delete result[field];
}
return result;
},
async update(data: UpdatePaymentSettingsInput): Promise<PaymentSettings> {
const toWrite = { ...data } as Record<string, unknown>;
// Encrypt sensitive fields
for (const field of ENCRYPTED_FIELDS) {
if (field in toWrite && typeof toWrite[field] === 'string' && toWrite[field]) {
toWrite[field] = encrypt(toWrite[field] as string);
}
}
// Handle donationSuggestedAmounts as JSON
if (data.donationSuggestedAmounts) {
toWrite.donationSuggestedAmounts = JSON.stringify(data.donationSuggestedAmounts);
}
const existing = await prisma.paymentSettings.findFirst();
let settings: PaymentSettings;
if (existing) {
settings = await prisma.paymentSettings.update({
where: { id: existing.id },
data: toWrite,
});
} else {
settings = await prisma.paymentSettings.create({ data: toWrite });
}
// Reset Stripe client so it picks up new keys
resetStripeClient();
return decryptSettings(settings);
},
};

View File

@@ -0,0 +1,369 @@
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 { paymentSettingsService } from './payment-settings.service';
import { subscriptionsService } from './subscriptions.service';
import { productsService } from './products.service';
import { donationsService } from './donations.service';
import {
updatePaymentSettingsSchema,
createPlanSchema,
updatePlanSchema,
createProductSchema,
updateProductSchema,
subscriptionFiltersSchema,
orderFiltersSchema,
refundDonationSchema,
} from './payments.schemas';
const router = Router();
// All admin routes require SUPER_ADMIN
router.use(authenticate, requireRole(UserRole.SUPER_ADMIN));
// =================== Settings ===================
// GET /api/payments/admin/settings
router.get('/settings', async (_req: Request, res: Response, next: NextFunction) => {
try {
const settings = await paymentSettingsService.get();
// Mask secret key for display
const masked = {
...settings,
stripeSecretKey: settings.stripeSecretKey ? '••••' + settings.stripeSecretKey.slice(-4) : '',
stripeWebhookSecret: settings.stripeWebhookSecret ? '••••' + settings.stripeWebhookSecret.slice(-4) : '',
};
res.json(masked);
} catch (err) {
next(err);
}
});
// PUT /api/payments/admin/settings
router.put(
'/settings',
validate(updatePaymentSettingsSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const settings = await paymentSettingsService.update(req.body);
res.json(settings);
} catch (err) {
next(err);
}
}
);
// POST /api/payments/admin/settings/test-connection
router.post('/settings/test-connection', async (_req: Request, res: Response, next: NextFunction) => {
try {
const { getStripe } = await import('../../services/stripe.client');
const stripe = await getStripe();
// Simple test: list 1 product
await stripe.products.list({ limit: 1 });
res.json({ success: true, message: 'Stripe connection verified' });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Connection failed';
res.json({ success: false, message });
}
});
// =================== Dashboard ===================
// GET /api/payments/admin/dashboard
router.get('/dashboard', async (_req: Request, res: Response, next: NextFunction) => {
try {
const [subStats, donationStats] = await Promise.all([
subscriptionsService.getDashboardStats(),
donationsService.getDonationStats(),
]);
res.json({
...subStats,
donations: donationStats,
});
} catch (err) {
next(err);
}
});
// =================== Plans ===================
// GET /api/payments/admin/plans
router.get('/plans', 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);
} catch (err) {
next(err);
}
});
// POST /api/payments/admin/plans
router.post(
'/plans',
validate(createPlanSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const plan = await subscriptionsService.createPlan(req.body);
res.status(201).json(plan);
} catch (err) {
next(err);
}
}
);
// PUT /api/payments/admin/plans/:id
router.put(
'/plans/:id',
validate(updatePlanSchema),
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);
res.json(plan);
} catch (err) {
next(err);
}
}
);
// DELETE /api/payments/admin/plans/:id
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);
res.json({ success: true });
} catch (err) {
next(err);
}
});
// POST /api/payments/admin/plans/:id/sync-stripe
router.post('/plans/:id/sync-stripe', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = parseInt(req.params.id as string, 10);
const plan = await subscriptionsService.syncPlanToStripe(id);
res.json(plan);
} catch (err) {
next(err);
}
});
// =================== Subscriptions ===================
// GET /api/payments/admin/subscriptions/export
router.get('/subscriptions/export', async (req: Request, res: Response, next: NextFunction) => {
try {
const search = req.query.search as string | undefined;
const status = req.query.status as import('@prisma/client').SubscriptionStatus | undefined;
const planId = req.query.planId ? parseInt(req.query.planId as string, 10) : undefined;
const csv = await subscriptionsService.exportToCsv({ search, status, planId });
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=subscriptions-export.csv');
res.send(csv);
} catch (err) {
next(err);
}
});
// GET /api/payments/admin/subscriptions
router.get(
'/subscriptions',
validate(subscriptionFiltersSchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await subscriptionsService.listSubscriptions(req.query as Record<string, unknown> as Parameters<typeof subscriptionsService.listSubscriptions>[0]);
res.json(result);
} catch (err) {
next(err);
}
}
);
// POST /api/payments/admin/subscriptions/:id/cancel
router.post('/subscriptions/:id/cancel', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = parseInt(req.params.id as string, 10);
const immediate = req.body?.immediate === true;
const sub = await subscriptionsService.cancelSubscription(id, immediate);
res.json(sub);
} catch (err) {
next(err);
}
});
// =================== Products ===================
// GET /api/payments/admin/products
router.get('/products', async (req: Request, res: Response, next: NextFunction) => {
try {
const page = parseInt(req.query.page as string, 10) || 1;
const limit = parseInt(req.query.limit as string, 10) || 20;
const type = req.query.type as string | undefined;
const search = req.query.search as string | undefined;
const result = await productsService.listAll({ page, limit, type, search });
res.json(result);
} catch (err) {
next(err);
}
});
// POST /api/payments/admin/products
router.post(
'/products',
validate(createProductSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const product = await productsService.create({
...req.body,
createdByUserId: req.user!.id,
});
res.status(201).json(product);
} catch (err) {
next(err);
}
}
);
// PUT /api/payments/admin/products/:id
router.put(
'/products/:id',
validate(updateProductSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const product = await productsService.update(req.params.id as string, req.body);
res.json(product);
} catch (err) {
next(err);
}
}
);
// DELETE /api/payments/admin/products/:id
router.delete('/products/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await productsService.delete(req.params.id as string);
res.json({ success: true });
} catch (err) {
next(err);
}
});
// POST /api/payments/admin/products/:id/sync-stripe
router.post('/products/:id/sync-stripe', async (req: Request, res: Response, next: NextFunction) => {
try {
const product = await productsService.syncProductToStripe(req.params.id as string);
res.json(product);
} catch (err) {
next(err);
}
});
// =================== Orders ===================
// GET /api/payments/admin/orders
router.get(
'/orders',
validate(orderFiltersSchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await productsService.listOrders(req.query as Record<string, unknown> as Parameters<typeof productsService.listOrders>[0]);
res.json(result);
} catch (err) {
next(err);
}
}
);
// POST /api/payments/admin/orders/:id/refund
router.post('/orders/:id/refund', async (req: Request, res: Response, next: NextFunction) => {
try {
const order = await productsService.refundOrder(req.params.id as string);
res.json(order);
} catch (err) {
next(err);
}
});
// =================== Donations ===================
// GET /api/payments/admin/donations/export
router.get('/donations/export', async (req: Request, res: Response, next: NextFunction) => {
try {
const search = req.query.search as string | undefined;
const status = req.query.status as string | undefined;
const csv = await donationsService.exportToCsv({ search, status });
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=donations-export.csv');
res.send(csv);
} catch (err) {
next(err);
}
});
// GET /api/payments/admin/donations
router.get('/donations', async (req: Request, res: Response, next: NextFunction) => {
try {
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 });
res.json(result);
} catch (err) {
next(err);
}
});
// POST /api/payments/admin/donations/:id/refund
router.post(
'/donations/:id/refund',
validate(refundDonationSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const order = await donationsService.refundDonation(
req.params.id as string,
req.body.reason,
);
res.json(order);
} catch (err) {
next(err);
}
}
);
// =================== CSV Export ===================
// GET /api/payments/admin/export
router.get('/export', async (_req: Request, res: Response, next: NextFunction) => {
try {
const { prisma } = await import('../../config/database');
const orders = await prisma.order.findMany({
where: { status: 'COMPLETED' },
include: { product: { select: { title: true } } },
orderBy: { createdAt: 'desc' },
});
const lines = ['Date,Type,Amount (CAD),Buyer Email,Buyer Name,Product,Status'];
for (const o of orders) {
lines.push([
o.createdAt.toISOString(),
o.type,
(o.amountCAD / 100).toFixed(2),
`"${o.buyerEmail}"`,
`"${o.buyerName || ''}"`,
`"${o.product?.title || ''}"`,
o.status,
].join(','));
}
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=payments-export.csv');
res.send(lines.join('\n'));
} catch (err) {
next(err);
}
});
export { router as paymentsAdminRouter };

View File

@@ -0,0 +1,149 @@
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 { productsService } from './products.service';
import { donationsService } from './donations.service';
import { authenticate } from '../../middleware/auth.middleware';
import { validate } from '../../middleware/validate';
import {
createSubscriptionCheckoutSchema,
createProductCheckoutSchema,
createDonationCheckoutSchema,
} from './payments.schemas';
const router = Router();
// GET /api/payments/config — public payment config (publishable key, donation settings)
router.get('/config', async (_req: Request, res: Response, next: NextFunction) => {
try {
const publishableKey = await getPublishableKey();
const settings = await paymentSettingsService.getPublic();
res.json({
publishableKey,
defaultCurrency: settings.defaultCurrency,
enableDonations: settings.enableDonations,
donationSuggestedAmounts: settings.donationSuggestedAmounts,
donationMinimum: settings.donationMinimum,
donationPageTitle: settings.donationPageTitle,
donationPageDescription: settings.donationPageDescription,
thankYouMessage: settings.thankYouMessage,
});
} catch (err) {
next(err);
}
});
// GET /api/payments/plans — list active subscription plans
router.get('/plans', async (_req: Request, res: Response, next: NextFunction) => {
try {
const plans = await subscriptionsService.listActivePlans();
res.json(plans);
} catch (err) {
next(err);
}
});
// GET /api/payments/products — list active products
router.get('/products', async (req: Request, res: Response, next: NextFunction) => {
try {
const type = req.query.type as string | undefined;
const products = await productsService.listActive(type);
res.json(products);
} catch (err) {
next(err);
}
});
// POST /api/payments/subscribe — create subscription checkout (requires login)
router.post(
'/subscribe',
authenticate,
validate(createSubscriptionCheckoutSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { planId, frequency } = req.body;
const result = await subscriptionsService.createCheckoutSession(
req.user!.id,
planId,
frequency,
);
res.json(result);
} catch (err) {
next(err);
}
}
);
// POST /api/payments/purchase — create product checkout (guest or logged-in)
router.post(
'/purchase',
validate(createProductCheckoutSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { productId, buyerEmail, buyerName } = req.body;
// Try to get user ID from optional auth
const userId = req.user?.id;
const result = await productsService.createProductCheckout(productId, buyerEmail, buyerName, userId);
res.json(result);
} catch (err) {
next(err);
}
}
);
// POST /api/payments/donate — create donation checkout (no auth required)
router.post(
'/donate',
validate(createDonationCheckoutSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { amountCents, email, name, message, isAnonymous } = req.body;
const result = await donationsService.createDonationCheckout(
amountCents,
email,
name,
message,
isAnonymous,
);
res.json(result);
} catch (err) {
next(err);
}
}
);
// GET /api/payments/my-subscription — current user's subscription
router.get(
'/my-subscription',
authenticate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const sub = await subscriptionsService.getActiveSubscription(req.user!.id);
res.json(sub || { status: 'none' });
} catch (err) {
next(err);
}
}
);
// POST /api/payments/my-subscription/cancel — cancel own subscription
router.post(
'/my-subscription/cancel',
authenticate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const sub = await subscriptionsService.getActiveSubscription(req.user!.id);
if (!sub) {
res.status(404).json({ error: { message: 'No active subscription', code: 'NOT_FOUND' } });
return;
}
const updated = await subscriptionsService.cancelSubscription(sub.id);
res.json(updated);
} catch (err) {
next(err);
}
}
);
export { router as paymentsPublicRouter };

View File

@@ -0,0 +1,100 @@
import { z } from 'zod';
// --- Payment Settings ---
export const updatePaymentSettingsSchema = z.object({
stripeSecretKey: z.string().max(500).optional(),
stripePublishableKey: z.string().max(500).optional(),
stripeWebhookSecret: z.string().max(500).optional(),
defaultCurrency: z.string().min(3).max(3).optional(),
enableDonations: z.boolean().optional(),
donationSuggestedAmounts: z.array(z.number().int().min(100)).optional(),
donationMinimum: z.number().int().min(100).optional(),
donationPageTitle: z.string().max(200).optional(),
donationPageDescription: z.string().max(2000).nullable().optional(),
thankYouMessage: z.string().max(2000).optional(),
});
export type UpdatePaymentSettingsInput = z.infer<typeof updatePaymentSettingsSchema>;
// --- Subscription Plans ---
export const createPlanSchema = z.object({
name: z.string().min(1).max(100),
priceCAD: z.number().int().min(0),
durationDays: z.number().int().min(1),
yearlyPriceCAD: z.number().int().min(0).nullable().optional(),
features: z.array(z.string()).optional(),
description: z.string().max(2000).nullable().optional(),
tier: z.number().int().min(0).optional(),
displayOrder: z.number().int().min(0).optional(),
isActive: z.boolean().optional(),
});
export const updatePlanSchema = createPlanSchema.partial();
// --- Subscribe ---
export const createSubscriptionCheckoutSchema = z.object({
planId: z.number().int(),
frequency: z.enum(['monthly', 'yearly']).default('monthly'),
});
// --- Products ---
export const createProductSchema = z.object({
title: z.string().min(1).max(200),
slug: z.string().min(1).max(200).regex(/^[a-z0-9-]+$/),
description: z.string().max(5000).nullable().optional(),
priceCAD: z.number().int().min(0),
type: z.enum(['DIGITAL', 'EVENT', 'DONATION']),
isActive: z.boolean().optional(),
imageUrl: z.string().url().nullable().optional().or(z.literal('')),
downloadUrl: z.string().max(1000).nullable().optional(),
metadata: z.record(z.unknown()).nullable().optional(),
maxPurchases: z.number().int().min(1).nullable().optional(),
});
export const updateProductSchema = createProductSchema.partial();
// --- Product Checkout ---
export const createProductCheckoutSchema = z.object({
productId: z.string(),
buyerEmail: z.string().email(),
buyerName: z.string().max(200).optional(),
});
// --- Donation ---
export const createDonationCheckoutSchema = 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(),
});
// --- Refund ---
export const refundDonationSchema = z.object({
reason: z.string().max(500).optional(),
});
// --- Admin filters ---
export const subscriptionFiltersSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
status: z.enum(['active', 'cancelled', 'grace_period', 'delinquent', 'none', 'lifetime']).optional(),
planId: z.coerce.number().int().optional(),
search: z.string().optional(),
});
export const orderFiltersSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
status: z.enum(['PENDING', 'COMPLETED', 'FAILED', 'REFUNDED']).optional(),
type: z.enum(['product', 'donation']).optional(),
search: z.string().optional(),
});

View File

@@ -0,0 +1,325 @@
import { prisma } from '../../config/database';
import { getStripe } from '../../services/stripe.client';
import { env } from '../../config/env';
import type { Prisma, OrderStatus, ProductType } from '@prisma/client';
import { logger } from '../../utils/logger';
/** Map product type to gallery ad defaults */
function productAdDefaults(product: { title: string; description: string | null; type: ProductType; slug: string; imageUrl: string | null; priceCAD: number }) {
const priceStr = `$${(product.priceCAD / 100).toFixed(2)}`;
const base = {
title: product.title,
subtitle: product.description
? product.description.slice(0, 120) + (product.description.length > 120 ? '...' : '')
: null,
imagePath: product.imageUrl ?? null,
variant: 'standard',
visibility: 'everyone',
isActive: false, // admin enables manually
isSystemAd: false,
frequency: 12,
position: 10,
};
switch (product.type) {
case 'DONATION':
return {
...base,
type: 'payment_donate',
linkUrl: '/donate',
ctaText: 'Donate Now',
ctaStyle: 'primary',
iconEmoji: null,
};
case 'EVENT':
return {
...base,
type: 'payment_shop',
linkUrl: `/shop/${product.slug}`,
ctaText: `Get Tickets \u2022 ${priceStr}`,
ctaStyle: 'primary',
iconEmoji: null,
};
default: // DIGITAL
return {
...base,
type: 'payment_shop',
linkUrl: `/shop/${product.slug}`,
ctaText: `Buy Now \u2022 ${priceStr}`,
ctaStyle: 'primary',
iconEmoji: null,
};
}
}
export const productsService = {
/** List active products (public) */
async listActive(type?: string) {
const where: Prisma.ProductWhereInput = { isActive: true };
if (type) where.type = type as Prisma.EnumProductTypeFilter['equals'];
return prisma.product.findMany({
where,
orderBy: { createdAt: 'desc' },
});
},
/** List all products (admin) */
async listAll(filters: { page: number; limit: number; type?: string; search?: string }) {
const { page, limit, type, search } = filters;
const where: Prisma.ProductWhereInput = {};
if (type) where.type = type as Prisma.EnumProductTypeFilter['equals'];
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ slug: { contains: search, mode: 'insensitive' } },
];
}
const [products, total] = await Promise.all([
prisma.product.findMany({
where,
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.product.count({ where }),
]);
return {
products,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async getById(id: string) {
return prisma.product.findUnique({ where: { id } });
},
async create(data: Prisma.ProductUncheckedCreateInput) {
const product = await prisma.product.create({ data });
// Auto-create a linked gallery ad only if the feature is enabled
try {
const settings = await prisma.siteSettings.findFirst();
if (settings?.enableGalleryAds) {
const adData = productAdDefaults(product);
await prisma.ad.create({
data: { ...adData, productId: product.id },
});
logger.info(`Auto-created gallery ad for product "${product.title}" (${product.id})`);
}
} catch (err) {
// Non-critical — log but don't fail the product creation
logger.warn(`Failed to auto-create gallery ad for product ${product.id}: ${err}`);
}
return product;
},
async update(id: string, data: Prisma.ProductUncheckedUpdateInput) {
const product = await prisma.product.update({ where: { id }, data });
// Sync linked gallery ad with updated product info (race-safe: updateMany is a no-op if no ad exists)
try {
const updates: Prisma.AdUncheckedUpdateManyInput = { updatedAt: new Date() };
if (data.title !== undefined) updates.title = data.title;
if (data.description !== undefined) {
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;
if (data.isActive === false) updates.isActive = false;
if (data.slug !== undefined) {
// Update link URL for non-donation products
updates.linkUrl = `/shop/${data.slug}`;
}
if (data.priceCAD !== undefined) {
const price = typeof data.priceCAD === 'number' ? data.priceCAD : product.priceCAD;
const priceStr = `$${(price / 100).toFixed(2)}`;
const verb = product.type === 'EVENT' ? 'Get Tickets' : 'Buy Now';
updates.ctaText = `${verb} \u2022 ${priceStr}`;
}
// updateMany with productId filter: no-op if no linked ad exists (no error thrown)
// Avoids race condition of find-then-update pattern
await prisma.ad.updateMany({
where: { productId: id },
data: updates,
});
} catch (err) {
logger.warn(`Failed to sync gallery ad for product ${id}: ${err}`);
}
return product;
},
async delete(id: string) {
// Deactivate linked gallery ad
try {
const linkedAd = await prisma.ad.findUnique({ where: { productId: id } });
if (linkedAd) {
await prisma.ad.update({
where: { productId: id },
data: { isActive: false, updatedAt: new Date() },
});
}
} catch (err) {
logger.warn(`Failed to deactivate gallery ad for product ${id}: ${err}`);
}
const orders = await prisma.order.count({ where: { productId: id, status: 'COMPLETED' } });
if (orders > 0) {
// Soft delete by deactivating
return prisma.product.update({ where: { id }, data: { isActive: false } });
}
return prisma.product.delete({ where: { id } });
},
/** Create Stripe Checkout for a product purchase */
async createProductCheckout(productId: string, buyerEmail: string, buyerName?: string, userId?: string) {
const stripe = await getStripe();
const product = await prisma.product.findUnique({ where: { id: productId } });
if (!product || !product.isActive) throw new Error('Product not found or inactive');
if (product.maxPurchases && product.purchaseCount >= product.maxPurchases) {
throw new Error('Product is sold out');
}
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{
price_data: {
currency: 'cad',
product_data: {
name: product.title,
description: product.description || undefined,
},
unit_amount: product.priceCAD,
},
quantity: 1,
}],
customer_email: buyerEmail,
success_url: `${env.ADMIN_URL}/payments/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.ADMIN_URL}/shop`,
metadata: {
type: 'product',
productId: product.id,
userId: userId || '',
buyerEmail,
buyerName: buyerName || '',
},
});
// Create pending order
await prisma.order.create({
data: {
userId: userId || null,
productId: product.id,
amountCAD: product.priceCAD,
status: 'PENDING',
stripeCheckoutSessionId: session.id,
type: 'product',
buyerEmail,
buyerName: buyerName || null,
},
});
return { sessionId: session.id, url: session.url };
},
/** Sync product to Stripe */
async syncProductToStripe(id: string) {
const stripe = await getStripe();
const product = await prisma.product.findUnique({ where: { id } });
if (!product) throw new Error('Product not found');
let stripeProductId = product.stripeProductId;
if (stripeProductId) {
await stripe.products.update(stripeProductId, {
name: product.title,
description: product.description || undefined,
active: product.isActive,
});
} else {
const sp = await stripe.products.create({
name: product.title,
description: product.description || undefined,
active: product.isActive,
metadata: { productId: product.id },
});
stripeProductId = sp.id;
}
let stripePriceId = product.stripePriceId;
if (!stripePriceId) {
const price = await stripe.prices.create({
product: stripeProductId,
unit_amount: product.priceCAD,
currency: 'cad',
});
stripePriceId = price.id;
}
return prisma.product.update({
where: { id },
data: { stripeProductId, stripePriceId },
});
},
/** List orders (admin) */
async listOrders(filters: {
page: number;
limit: number;
status?: OrderStatus;
type?: string;
search?: string;
}) {
const { page, limit, status, type, search } = filters;
const where: Prisma.OrderWhereInput = {};
if (status) where.status = status;
if (type) where.type = type;
if (search) {
where.OR = [
{ buyerEmail: { contains: search, mode: 'insensitive' } },
{ buyerName: { contains: search, mode: 'insensitive' } },
];
}
const [orders, total] = await Promise.all([
prisma.order.findMany({
where,
include: {
product: { select: { id: true, title: true, slug: true, type: true } },
user: { select: { id: true, email: true, name: true } },
},
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.order.count({ where }),
]);
return {
orders,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
/** Refund an order via Stripe */
async refundOrder(orderId: string) {
const order = await prisma.order.findUnique({ where: { id: orderId } });
if (!order) throw new Error('Order not found');
if (order.status !== 'COMPLETED') throw new Error('Can only refund completed orders');
if (order.stripePaymentIntentId) {
const stripe = await getStripe();
await stripe.refunds.create({ payment_intent: order.stripePaymentIntentId });
}
return prisma.order.update({
where: { id: orderId },
data: { status: 'REFUNDED' },
});
},
};

View File

@@ -0,0 +1,329 @@
import { prisma } from '../../config/database';
import { getStripe, getPublishableKey } from '../../services/stripe.client';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
import type { SubscriptionStatus, Prisma } from '@prisma/client';
import { stringify } from 'csv-stringify/sync';
export const subscriptionsService = {
/** Create a Stripe Checkout session for a subscription */
async createCheckoutSession(userId: string, planId: number, frequency: 'monthly' | 'yearly') {
const stripe = await getStripe();
const plan = await prisma.subscriptionPlan.findUnique({ where: { id: planId } });
if (!plan || !plan.isActive) throw new Error('Plan not found or inactive');
const priceId = frequency === 'yearly' ? plan.stripeYearlyPriceId : plan.stripePriceId;
if (!priceId) throw new Error(`Plan has no Stripe ${frequency} price ID configured`);
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new Error('User not found');
// Check for existing active sub
const existing = await prisma.userSubscription.findFirst({
where: { userId, status: 'active' },
});
if (existing) throw new Error('User already has an active subscription');
// Find or create Stripe customer
let customerId: string | undefined;
const existingSub = await prisma.userSubscription.findFirst({
where: { userId, stripeCustomerId: { not: null } },
orderBy: { createdAt: 'desc' },
});
if (existingSub?.stripeCustomerId) {
customerId = existingSub.stripeCustomerId;
} else {
const customer = await stripe.customers.create({
email: user.email,
name: user.name || undefined,
metadata: { userId },
});
customerId = customer.id;
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${env.ADMIN_URL}/payments/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.ADMIN_URL}/pricing`,
metadata: {
type: 'subscription',
userId,
planId: planId.toString(),
frequency,
},
});
return { sessionId: session.id, url: session.url };
},
/** Cancel a subscription (at period end by default) */
async cancelSubscription(subscriptionId: number, immediate = false) {
const sub = await prisma.userSubscription.findUnique({ where: { id: subscriptionId } });
if (!sub) throw new Error('Subscription not found');
if (sub.stripeSubscriptionId) {
const stripe = await getStripe();
if (immediate) {
await stripe.subscriptions.cancel(sub.stripeSubscriptionId);
} else {
await stripe.subscriptions.update(sub.stripeSubscriptionId, {
cancel_at_period_end: true,
});
}
}
return prisma.userSubscription.update({
where: { id: subscriptionId },
data: {
cancelAtPeriodEnd: !immediate,
cancelledAt: immediate ? new Date() : undefined,
status: immediate ? 'cancelled' : undefined,
},
});
},
/** Get active subscription for a user */
async getActiveSubscription(userId: string) {
return prisma.userSubscription.findFirst({
where: {
userId,
status: { in: ['active', 'grace_period'] },
},
include: { plan: true },
orderBy: { createdAt: 'desc' },
});
},
/** List all subscriptions (admin) */
async listSubscriptions(filters: {
page: number;
limit: number;
status?: SubscriptionStatus;
planId?: number;
search?: string;
}) {
const { page, limit, status, planId, search } = filters;
const where: Prisma.UserSubscriptionWhereInput = {};
if (status) where.status = status;
if (planId) where.planId = planId;
if (search) {
where.user = {
OR: [
{ email: { contains: search, mode: 'insensitive' } },
{ name: { contains: search, mode: 'insensitive' } },
],
};
}
const [subscriptions, total] = await Promise.all([
prisma.userSubscription.findMany({
where,
include: { plan: true, user: { select: { id: true, email: true, name: true } } },
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.userSubscription.count({ where }),
]);
return {
subscriptions,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
/** Sync subscription from Stripe (e.g. after webhook) */
async activateFromCheckout(
userId: string,
planId: number,
stripeSubscriptionId: string,
stripeCustomerId: string,
currentPeriodEnd: Date,
) {
// Deactivate any existing active sub
await prisma.userSubscription.updateMany({
where: { userId, status: 'active' },
data: { status: 'cancelled', cancelledAt: new Date() },
});
return prisma.userSubscription.create({
data: {
userId,
planId,
status: 'active',
startDate: new Date(),
endDate: currentPeriodEnd,
stripeSubscriptionId,
stripeCustomerId,
currentPeriodEnd,
},
});
},
/** Sync to Stripe: create/update product + prices for a plan */
async syncPlanToStripe(planId: number) {
const stripe = await getStripe();
const plan = await prisma.subscriptionPlan.findUnique({ where: { id: planId } });
if (!plan) throw new Error('Plan not found');
let stripeProductId = plan.stripeProductId;
// Create or update Stripe product
if (stripeProductId) {
await stripe.products.update(stripeProductId, {
name: plan.name,
description: plan.description || undefined,
active: plan.isActive,
});
} else {
const product = await stripe.products.create({
name: plan.name,
description: plan.description || undefined,
active: plan.isActive,
metadata: { planId: plan.id.toString() },
});
stripeProductId = product.id;
}
// Create monthly price if needed
let stripePriceId = plan.stripePriceId;
if (!stripePriceId && plan.priceCAD > 0) {
const price = await stripe.prices.create({
product: stripeProductId,
unit_amount: plan.priceCAD,
currency: 'cad',
recurring: { interval: 'month' },
});
stripePriceId = price.id;
}
// Create yearly price if needed
let stripeYearlyPriceId = plan.stripeYearlyPriceId;
if (!stripeYearlyPriceId && plan.yearlyPriceCAD && plan.yearlyPriceCAD > 0) {
const price = await stripe.prices.create({
product: stripeProductId,
unit_amount: plan.yearlyPriceCAD,
currency: 'cad',
recurring: { interval: 'year' },
});
stripeYearlyPriceId = price.id;
}
return prisma.subscriptionPlan.update({
where: { id: planId },
data: { stripeProductId, stripePriceId, stripeYearlyPriceId },
});
},
/** Export subscriptions to CSV */
async exportToCsv(filters: { search?: string; status?: SubscriptionStatus; planId?: number }) {
const where: Prisma.UserSubscriptionWhereInput = {};
if (filters.status) where.status = filters.status;
if (filters.planId) where.planId = filters.planId;
if (filters.search) {
where.user = {
OR: [
{ email: { contains: filters.search, mode: 'insensitive' } },
{ name: { contains: filters.search, mode: 'insensitive' } },
],
};
}
const subscriptions = await prisma.userSubscription.findMany({
where,
include: { plan: true, user: { select: { id: true, email: true, name: true } } },
orderBy: { createdAt: 'desc' },
});
return stringify(subscriptions.map((s) => ({
'User Name': s.user?.name || '',
'User Email': s.user?.email || '',
'Plan': s.plan?.name || '',
'Price (CAD/mo)': s.plan ? (s.plan.priceCAD / 100).toFixed(2) : '',
'Status': s.status,
'Started': s.startDate.toISOString(),
'Current Period End': s.currentPeriodEnd ? s.currentPeriodEnd.toISOString() : '',
'Cancel at Period End': s.cancelAtPeriodEnd ? 'Yes' : 'No',
'Cancelled At': s.cancelledAt ? s.cancelledAt.toISOString() : '',
'Stripe Subscription ID': s.stripeSubscriptionId || '',
'Stripe Customer ID': s.stripeCustomerId || '',
'Subscription ID': s.id.toString(),
'User ID': s.userId,
})), { header: true });
},
/** List active plans for public display */
async listActivePlans() {
return prisma.subscriptionPlan.findMany({
where: { isActive: true },
orderBy: { displayOrder: 'asc' },
});
},
/** Admin CRUD for plans */
async createPlan(data: Prisma.SubscriptionPlanUncheckedCreateInput) {
return prisma.subscriptionPlan.create({ data });
},
async updatePlan(id: number, data: Prisma.SubscriptionPlanUncheckedUpdateInput) {
return prisma.subscriptionPlan.update({ where: { id }, data });
},
async deletePlan(id: number) {
// Check for active subscriptions
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 } });
},
/** Dashboard stats */
async getDashboardStats() {
const [
activeSubscribers,
totalRevenue,
plans,
recentSubscriptions,
] = await Promise.all([
prisma.userSubscription.count({ where: { status: 'active' } }),
prisma.order.aggregate({
where: { status: 'COMPLETED' },
_sum: { amountCAD: true },
}),
prisma.subscriptionPlan.findMany({ where: { isActive: true } }),
prisma.userSubscription.findMany({
where: { status: 'active' },
include: { plan: true },
orderBy: { createdAt: 'desc' },
take: 10,
}),
]);
// Calculate MRR from active subscriptions
let mrr = 0;
for (const sub of recentSubscriptions) {
mrr += sub.plan.priceCAD;
}
// Count total active subs for accurate MRR
const allActiveSubs = await prisma.userSubscription.findMany({
where: { status: 'active' },
include: { plan: true },
});
mrr = allActiveSubs.reduce((sum, sub) => sum + sub.plan.priceCAD, 0);
return {
activeSubscribers,
totalRevenue: totalRevenue._sum.amountCAD || 0,
mrr,
planCount: plans.length,
};
},
};

View File

@@ -0,0 +1,358 @@
import Stripe from 'stripe';
import { prisma } from '../../config/database';
import { getStripe, getWebhookSecret } from '../../services/stripe.client';
import { logger } from '../../utils/logger';
import { paymentEmailService } from './payment-email.service';
// Helper to extract subscription ID from invoice (may be string, object, or missing in newer types)
function getSubscriptionId(invoice: Stripe.Invoice): string | null {
const raw = invoice as unknown as Record<string, unknown>;
const sub = raw.subscription;
if (!sub) return null;
if (typeof sub === 'string') return sub;
if (typeof sub === 'object' && sub !== null && 'id' in sub) return (sub as { id: string }).id;
return null;
}
export const webhookService = {
/** Verify and parse a webhook event */
async constructEvent(rawBody: Buffer, signature: string): Promise<Stripe.Event> {
const stripe = await getStripe();
const secret = await getWebhookSecret();
if (!secret) throw new Error('Webhook secret not configured');
return stripe.webhooks.constructEvent(rawBody, signature, secret);
},
/** Route and handle a webhook event */
async handleEvent(event: Stripe.Event): Promise<void> {
logger.info(`Stripe webhook: ${event.type} (${event.id})`);
switch (event.type) {
case 'checkout.session.completed':
await this.handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
break;
case 'invoice.paid':
await this.handleInvoicePaid(event.data.object as Stripe.Invoice);
break;
case 'invoice.payment_failed':
await this.handleInvoicePaymentFailed(event.data.object as Stripe.Invoice);
break;
case 'customer.subscription.updated':
await this.handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
break;
case 'charge.refunded':
await this.handleChargeRefunded(event.data.object as Stripe.Charge);
break;
default:
logger.debug(`Unhandled Stripe event type: ${event.type}`);
}
},
async handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const type = session.metadata?.type;
if (type === 'subscription') {
await this.handleSubscriptionCheckout(session);
} else if (type === 'product') {
await this.handleProductCheckout(session);
} else if (type === 'donation') {
await this.handleDonationCheckout(session);
} else {
logger.warn(`Unknown checkout type: ${type}`);
}
},
async handleSubscriptionCheckout(session: Stripe.Checkout.Session) {
const { userId, planId } = session.metadata || {};
if (!userId || !planId) {
logger.error('Missing metadata in subscription checkout', { sessionId: session.id });
return;
}
const stripe = await getStripe();
const subscriptionId = typeof session.subscription === 'string'
? session.subscription
: (session.subscription as { id: string } | null)?.id;
const customerId = typeof session.customer === 'string'
? session.customer
: (session.customer as { id: string } | null)?.id;
if (!subscriptionId || !customerId) {
logger.error('Missing subscription or customer ID in checkout session');
return;
}
// Get subscription details from Stripe
const stripeSub = await stripe.subscriptions.retrieve(subscriptionId) as unknown as {
current_period_end: number;
cancel_at_period_end: boolean;
};
const currentPeriodEnd = new Date(stripeSub.current_period_end * 1000);
// Check idempotency
const existing = await prisma.userSubscription.findUnique({
where: { stripeSubscriptionId: subscriptionId },
});
if (existing) {
logger.info(`Subscription already exists for ${subscriptionId}`);
return;
}
// Deactivate existing active subs
await prisma.userSubscription.updateMany({
where: { userId, status: 'active' },
data: { status: 'cancelled', cancelledAt: new Date() },
});
await prisma.userSubscription.create({
data: {
userId,
planId: parseInt(planId, 10),
status: 'active',
startDate: new Date(),
endDate: currentPeriodEnd,
stripeSubscriptionId: subscriptionId,
stripeCustomerId: customerId,
currentPeriodEnd,
},
});
await this.createAuditLog('subscription_created', { userId, planId, subscriptionId });
logger.info(`Subscription activated for user ${userId}, plan ${planId}`);
// Send subscription welcome email (fire-and-forget)
await paymentEmailService.sendSubscriptionWelcome({
userId,
planId: parseInt(planId, 10),
stripeSubscriptionId: subscriptionId,
currentPeriodEnd,
});
},
async handleProductCheckout(session: Stripe.Checkout.Session) {
const order = await prisma.order.findUnique({
where: { stripeCheckoutSessionId: session.id },
});
if (!order) {
logger.error('Order not found for checkout session', { sessionId: session.id });
return;
}
if (order.status === 'COMPLETED') return; // idempotent
const paymentIntentId = typeof session.payment_intent === 'string'
? session.payment_intent
: (session.payment_intent as { id: string } | null)?.id || null;
await prisma.$transaction([
prisma.order.update({
where: { id: order.id },
data: {
status: 'COMPLETED',
stripePaymentIntentId: paymentIntentId,
completedAt: new Date(),
},
}),
// Increment purchase count
...(order.productId ? [
prisma.product.update({
where: { id: order.productId },
data: { purchaseCount: { increment: 1 } },
}),
] : []),
]);
await this.createAuditLog('product_purchased', {
orderId: order.id,
productId: order.productId,
amount: order.amountCAD,
});
logger.info(`Product order completed: ${order.id}`);
// Send product receipt (fire-and-forget)
const updatedOrder = await prisma.order.findUnique({
where: { id: order.id },
include: { product: { select: { title: true, type: true } } },
});
if (updatedOrder) {
await paymentEmailService.sendProductReceipt({
id: updatedOrder.id,
buyerEmail: updatedOrder.buyerEmail || '',
buyerName: updatedOrder.buyerName,
amountCAD: updatedOrder.amountCAD,
completedAt: updatedOrder.completedAt,
product: updatedOrder.product,
});
}
},
async handleDonationCheckout(session: Stripe.Checkout.Session) {
const order = await prisma.order.findUnique({
where: { stripeCheckoutSessionId: session.id },
});
if (!order) {
logger.error('Donation order not found for checkout session', { sessionId: session.id });
return;
}
if (order.status === 'COMPLETED') return; // idempotent
const paymentIntentId = typeof session.payment_intent === 'string'
? session.payment_intent
: (session.payment_intent as { id: string } | null)?.id || null;
await prisma.order.update({
where: { id: order.id },
data: {
status: 'COMPLETED',
stripePaymentIntentId: paymentIntentId,
completedAt: new Date(),
},
});
await this.createAuditLog('donation_completed', {
orderId: order.id,
amount: order.amountCAD,
email: order.buyerEmail,
});
logger.info(`Donation completed: ${order.id}, $${(order.amountCAD / 100).toFixed(2)}`);
// Send donation receipt (fire-and-forget, errors logged but not thrown)
await paymentEmailService.sendDonationReceipt({
id: order.id,
buyerEmail: order.buyerEmail || '',
buyerName: order.buyerName,
amountCAD: order.amountCAD,
donorMessage: order.donorMessage,
isAnonymous: order.isAnonymous,
completedAt: new Date(),
});
},
async handleInvoicePaid(invoice: Stripe.Invoice) {
const subscriptionId = getSubscriptionId(invoice);
if (!subscriptionId) return;
const sub = await prisma.userSubscription.findUnique({
where: { stripeSubscriptionId: subscriptionId },
});
if (!sub) return;
const stripe = await getStripe();
const stripeSub = await stripe.subscriptions.retrieve(subscriptionId) as unknown as {
current_period_end: number;
};
const currentPeriodEnd = new Date(stripeSub.current_period_end * 1000);
await prisma.userSubscription.update({
where: { id: sub.id },
data: {
status: 'active',
currentPeriodEnd,
endDate: currentPeriodEnd,
},
});
logger.info(`Invoice paid, subscription ${subscriptionId} renewed to ${currentPeriodEnd}`);
},
async handleInvoicePaymentFailed(invoice: Stripe.Invoice) {
const subscriptionId = getSubscriptionId(invoice);
if (!subscriptionId) return;
const sub = await prisma.userSubscription.findUnique({
where: { stripeSubscriptionId: subscriptionId },
});
if (!sub) return;
await prisma.userSubscription.update({
where: { id: sub.id },
data: { status: 'grace_period' },
});
await this.createAuditLog('payment_failed', { subscriptionId, userId: sub.userId });
logger.warn(`Payment failed for subscription ${subscriptionId}`);
},
async handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const sub = await prisma.userSubscription.findUnique({
where: { stripeSubscriptionId: subscription.id },
});
if (!sub) return;
const rawSub = subscription as unknown as {
current_period_end: number;
cancel_at_period_end: boolean;
};
const currentPeriodEnd = new Date(rawSub.current_period_end * 1000);
await prisma.userSubscription.update({
where: { id: sub.id },
data: {
cancelAtPeriodEnd: rawSub.cancel_at_period_end,
currentPeriodEnd,
endDate: currentPeriodEnd,
},
});
},
async handleSubscriptionDeleted(subscription: Stripe.Subscription) {
const sub = await prisma.userSubscription.findUnique({
where: { stripeSubscriptionId: subscription.id },
});
if (!sub) return;
await prisma.userSubscription.update({
where: { id: sub.id },
data: {
status: 'cancelled',
cancelledAt: new Date(),
},
});
await this.createAuditLog('subscription_cancelled', {
subscriptionId: subscription.id,
userId: sub.userId,
});
logger.info(`Subscription cancelled: ${subscription.id}`);
},
async handleChargeRefunded(charge: Stripe.Charge) {
const paymentIntentId = typeof charge.payment_intent === 'string'
? charge.payment_intent
: (charge.payment_intent as { id: string } | null)?.id;
if (!paymentIntentId) return;
// Check orders
const order = await prisma.order.findFirst({
where: { stripePaymentIntentId: paymentIntentId },
});
if (order && order.status !== 'REFUNDED') {
await prisma.order.update({
where: { id: order.id },
data: { status: 'REFUNDED' },
});
await this.createAuditLog('order_refunded', { orderId: order.id });
}
// Check payments
const payment = await prisma.payment.findFirst({
where: { stripePaymentIntentId: paymentIntentId },
});
if (payment && payment.status !== 'refunded') {
await prisma.payment.update({
where: { id: payment.id },
data: { status: 'refunded' },
});
}
},
async createAuditLog(action: string, metadata: Record<string, unknown>) {
try {
logger.info(`Payment audit: ${action}`, metadata);
} catch (err) {
logger.error('Failed to create audit log', err);
}
},
};

View File

@@ -29,7 +29,7 @@ router.get(
requireRole(UserRole.SUPER_ADMIN),
async (_req: Request, res: Response, next: NextFunction) => {
try {
const settings = await siteSettingsService.get();
const settings = await siteSettingsService.getEffective();
res.json(settings);
} catch (err) {
next(err);

View File

@@ -46,6 +46,9 @@ export const updateSiteSettingsSchema = z.object({
enableMap: z.boolean().optional(),
enableNewsletter: z.boolean().optional(),
enableLandingPages: z.boolean().optional(),
enableMediaFeatures: z.boolean().optional(),
enablePayments: z.boolean().optional(),
enableGalleryAds: z.boolean().optional(),
});
export type UpdateSiteSettingsInput = z.infer<typeof updateSiteSettingsSchema>;

View File

@@ -2,6 +2,7 @@ import { prisma } from '../../config/database';
import type { SiteSettings } from '@prisma/client';
import type { UpdateSiteSettingsInput } from './settings.schemas';
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;
@@ -30,6 +31,48 @@ export const siteSettingsService = {
return decryptSettings(settings);
},
/** Full settings + _effective object resolving actual runtime SMTP config (admin use) */
async getEffective() {
const settings = await this.get();
const provider = settings.smtpActiveProvider || 'mailhog';
let host: string, port: number, user: string, hasPassword: boolean, fromAddress: string, fromName: string;
if (provider === 'mailhog') {
host = 'mailhog-changemaker';
port = 1025;
user = '';
hasPassword = false;
fromAddress = settings.smtpFromAddress || env.SMTP_FROM;
fromName = settings.emailFromName || env.SMTP_FROM_NAME;
} else {
host = settings.smtpHost || env.SMTP_HOST;
port = settings.smtpPort || env.SMTP_PORT;
user = settings.smtpUser || env.SMTP_USER;
hasPassword = !!(settings.smtpPass || env.SMTP_PASS);
fromAddress = settings.smtpFromAddress || env.SMTP_FROM;
fromName = settings.emailFromName || env.SMTP_FROM_NAME;
}
const testMode = settings.emailTestMode;
const testRecipient = settings.testEmailRecipient || env.TEST_EMAIL_RECIPIENT;
return {
...settings,
_effective: {
provider,
host,
port,
user,
hasPassword,
fromAddress,
fromName,
testMode,
testRecipient,
},
};
},
/** Public-safe settings (strips SMTP credentials) */
async getPublic(): Promise<Omit<SiteSettings, typeof SENSITIVE_FIELDS[number]>> {
const settings = await this.get();

View File

@@ -52,6 +52,14 @@ import { canvassService } from './modules/map/canvass/canvass.service';
import { trackingService } from './modules/map/tracking/tracking.service';
import { verificationTokenService } from './services/verification-token.service';
import { passwordResetTokenService } from './services/password-reset-token.service';
import { paymentsPublicRouter } from './modules/payments/payments-public.routes';
import { paymentsAdminRouter } from './modules/payments/payments-admin.routes';
import { webhookService } from './modules/payments/webhook.service';
import { galleryAdsPublicRouter } from './modules/gallery-ads/gallery-ads-public.routes';
import { galleryAdsAdminRouter } from './modules/gallery-ads/gallery-ads-admin.routes';
import { effectivenessRouter } from './modules/influence/effectiveness/effectiveness.routes';
import { docsAnalyticsPublicRouter, docsAnalyticsAdminRouter } from './modules/docs-analytics/docs-analytics.routes';
import { docsAnalyticsService } from './modules/docs-analytics/docs-analytics.service';
const app = express();
@@ -69,6 +77,24 @@ app.use(cors({
}));
app.use(compression());
// Stripe webhook — must receive raw body BEFORE express.json() parses it
app.post('/api/payments/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.headers['stripe-signature'];
if (!signature || typeof signature !== 'string') {
res.status(400).json({ error: 'Missing stripe-signature header' });
return;
}
try {
const event = await webhookService.constructEvent(req.body as Buffer, signature);
await webhookService.handleEvent(event);
res.json({ received: true });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Webhook error';
res.status(400).json({ error: message });
}
});
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
app.use(globalRateLimit);
@@ -156,6 +182,13 @@ app.use('/api/settings', siteSettingsRouter); // Site settings (pub
app.use('/api/pangolin', pangolinRouter); // Pangolin tunnel management (SUPER_ADMIN)
app.use('/api/observability', observabilityRouter); // Observability / monitoring (SUPER_ADMIN)
app.use('/api/dashboard', dashboardRouter); // Dashboard summary (ADMIN roles)
app.use('/api/payments', paymentsPublicRouter); // Public payment routes (plans, checkout, my subscription)
app.use('/api/payments/admin', paymentsAdminRouter); // Admin payment management (SUPER_ADMIN)
app.use('/api/influence/effectiveness', effectivenessRouter); // Campaign effectiveness analytics (ADMIN)
app.use('/api/gallery-ads', galleryAdsPublicRouter); // Public gallery ads (optional auth)
app.use('/api/gallery-ads/admin', galleryAdsAdminRouter); // Admin gallery ad CRUD (SUPER_ADMIN)
app.use('/api/docs-analytics', docsAnalyticsPublicRouter); // Public docs page view tracking (no auth)
app.use('/api/docs-analytics', docsAnalyticsAdminRouter); // Admin docs analytics (ADMIN roles)
// --- Error Handler (must be last) ---
app.use(errorHandler);
@@ -202,6 +235,10 @@ async function start() {
trackingService.closeStaleTrackingSessions(120).catch(() => {});
setInterval(() => trackingService.closeStaleTrackingSessions(120).catch(() => {}), 60 * 60 * 1000);
// Clean old docs analytics data on startup + daily (90-day retention)
docsAnalyticsService.cleanupOldData(90).catch(() => {});
setInterval(() => docsAnalyticsService.cleanupOldData(90).catch(() => {}), 24 * 60 * 60 * 1000);
// Sync MkDocs overrides on startup
pagesService.syncOverrides()
.then(({ imported, updated }) => {

View File

@@ -0,0 +1,44 @@
import Stripe from 'stripe';
import { prisma } from '../config/database';
import { decrypt } from '../utils/crypto';
import { logger } from '../utils/logger';
let _stripe: Stripe | null = null;
/** Get (or lazily create) the Stripe client, reading keys from PaymentSettings */
export async function getStripe(): Promise<Stripe> {
if (_stripe) return _stripe;
const settings = await prisma.paymentSettings.findFirst();
if (!settings) {
throw new Error('Payment settings not configured — set Stripe keys in admin settings');
}
const secretKey = decrypt(settings.stripeSecretKey);
if (!secretKey) {
throw new Error('Stripe secret key not configured — set it in admin payment settings');
}
_stripe = new Stripe(secretKey);
logger.info('Stripe client initialized');
return _stripe;
}
/** Force re-initialization after settings change */
export function resetStripeClient(): void {
_stripe = null;
}
/** Get the publishable key (for public config endpoint) */
export async function getPublishableKey(): Promise<string> {
const settings = await prisma.paymentSettings.findFirst();
return settings?.stripePublishableKey || '';
}
/** Get the webhook secret (decrypted) */
export async function getWebhookSecret(): Promise<string> {
const settings = await prisma.paymentSettings.findFirst();
if (!settings?.stripeWebhookSecret) return '';
return decrypt(settings.stripeWebhookSecret);
}

View File

@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Donation Receipt — {{ORGANIZATION_NAME}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #f9f9f9;
border-radius: 8px;
padding: 30px;
border: 1px solid #e0e0e0;
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #52c41a;
padding-bottom: 20px;
}
.logo {
color: #52c41a;
font-size: 24px;
font-weight: bold;
}
.badge {
background: linear-gradient(135deg, #52c41a, #389e0d);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
display: inline-block;
margin-top: 10px;
}
.content {
background-color: white;
padding: 25px;
border-radius: 6px;
margin-bottom: 20px;
border-left: 4px solid #52c41a;
}
.amount-box {
text-align: center;
background-color: #f6ffed;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
border: 1px solid #b7eb8f;
}
.amount {
font-size: 36px;
font-weight: bold;
color: #389e0d;
}
.info-section {
background-color: #f6ffed;
padding: 15px;
border-radius: 4px;
margin: 20px 0;
border: 1px solid #b7eb8f;
}
.info-item {
margin: 8px 0;
}
.info-label {
font-weight: bold;
display: inline-block;
width: 120px;
color: #237804;
}
.info-value {
color: #2c3e50;
}
.message-section {
background-color: #fffbe6;
padding: 15px;
border-radius: 4px;
margin: 20px 0;
border: 1px solid #ffe58f;
font-style: italic;
}
.footer {
text-align: center;
font-size: 12px;
color: #6c757d;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
}
.app-branding {
color: #52c41a;
font-weight: bold;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">{{ORGANIZATION_NAME}}</div>
<div class="badge">Donation Receipt</div>
</div>
<div class="content">
<p>Hi {{RECIPIENT_NAME}},</p>
<p>Thank you for your generous donation! Here are the details of your contribution:</p>
<div class="amount-box">
<div class="amount">{{AMOUNT}}</div>
<div style="color: #6c757d; font-size: 14px; margin-top: 4px;">CAD</div>
</div>
<div class="info-section">
<div class="info-item">
<span class="info-label">Reference ID:</span>
<span class="info-value">{{ORDER_ID}}</span>
</div>
<div class="info-item">
<span class="info-label">Date:</span>
<span class="info-value">{{DONATION_DATE}}</span>
</div>
</div>
{{#if DONOR_MESSAGE}}
<div class="message-section">
<strong>Your message:</strong>
<p style="margin: 8px 0 0 0;">{{DONOR_MESSAGE}}</p>
</div>
{{/if}}
{{#if IS_ANONYMOUS}}
<p style="color: #6c757d; font-size: 13px;">Your donation has been recorded as anonymous.</p>
{{/if}}
<p>Your support makes a real difference. Thank you for being part of our community!</p>
</div>
<div class="footer">
<p>This receipt was sent by <span class="app-branding">{{ORGANIZATION_NAME}}</span></p>
<p>Please keep this email for your records.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,26 @@
{{ORGANIZATION_NAME}} — Donation Receipt
Hi {{RECIPIENT_NAME}},
Thank you for your generous donation!
Donation Details:
- Amount: {{AMOUNT}} CAD
- Reference ID: {{ORDER_ID}}
- Date: {{DONATION_DATE}}
{{#if DONOR_MESSAGE}}
Your message:
{{DONOR_MESSAGE}}
{{/if}}
{{#if IS_ANONYMOUS}}
Your donation has been recorded as anonymous.
{{/if}}
Your support makes a real difference. Thank you for being part of our community!
---
This receipt was sent by {{ORGANIZATION_NAME}}.
Please keep this email for your records.

View File

@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Purchase Receipt — {{ORGANIZATION_NAME}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #f9f9f9;
border-radius: 8px;
padding: 30px;
border: 1px solid #e0e0e0;
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #1890ff;
padding-bottom: 20px;
}
.logo {
color: #1890ff;
font-size: 24px;
font-weight: bold;
}
.badge {
background: linear-gradient(135deg, #1890ff, #096dd9);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
display: inline-block;
margin-top: 10px;
}
.content {
background-color: white;
padding: 25px;
border-radius: 6px;
margin-bottom: 20px;
border-left: 4px solid #1890ff;
}
.product-box {
text-align: center;
background-color: #e6f7ff;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
border: 1px solid #91d5ff;
}
.product-title {
font-size: 20px;
font-weight: bold;
color: #096dd9;
}
.product-type {
display: inline-block;
padding: 2px 10px;
border-radius: 4px;
background: #1890ff;
color: #fff;
font-size: 11px;
font-weight: 600;
margin-top: 8px;
}
.amount {
font-size: 28px;
font-weight: bold;
color: #096dd9;
margin-top: 10px;
}
.info-section {
background-color: #e6f7ff;
padding: 15px;
border-radius: 4px;
margin: 20px 0;
border: 1px solid #91d5ff;
}
.info-item {
margin: 8px 0;
}
.info-label {
font-weight: bold;
display: inline-block;
width: 120px;
color: #0050b3;
}
.info-value {
color: #2c3e50;
}
.footer {
text-align: center;
font-size: 12px;
color: #6c757d;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
}
.app-branding {
color: #1890ff;
font-weight: bold;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">{{ORGANIZATION_NAME}}</div>
<div class="badge">Purchase Receipt</div>
</div>
<div class="content">
<p>Hi {{RECIPIENT_NAME}},</p>
<p>Thank you for your purchase! Here are the details:</p>
<div class="product-box">
<div class="product-title">{{PRODUCT_TITLE}}</div>
<div class="product-type">{{PRODUCT_TYPE}}</div>
<div class="amount">{{AMOUNT}}</div>
<div style="color: #6c757d; font-size: 14px;">CAD</div>
</div>
<div class="info-section">
<div class="info-item">
<span class="info-label">Order ID:</span>
<span class="info-value">{{ORDER_ID}}</span>
</div>
<div class="info-item">
<span class="info-label">Date:</span>
<span class="info-value">{{PURCHASE_DATE}}</span>
</div>
</div>
<p>If you have any questions about your purchase, please don't hesitate to reach out.</p>
</div>
<div class="footer">
<p>This receipt was sent by <span class="app-branding">{{ORGANIZATION_NAME}}</span></p>
<p>Please keep this email for your records.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,19 @@
{{ORGANIZATION_NAME}} — Purchase Receipt
Hi {{RECIPIENT_NAME}},
Thank you for your purchase!
Purchase Details:
- Product: {{PRODUCT_TITLE}}
- Type: {{PRODUCT_TYPE}}
- Amount: {{AMOUNT}} CAD
- Order ID: {{ORDER_ID}}
- Date: {{PURCHASE_DATE}}
If you have any questions about your purchase, please don't hesitate to reach out.
---
This receipt was sent by {{ORGANIZATION_NAME}}.
Please keep this email for your records.

View File

@@ -0,0 +1,156 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Welcome to {{PLAN_NAME}} — {{ORGANIZATION_NAME}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #f9f9f9;
border-radius: 8px;
padding: 30px;
border: 1px solid #e0e0e0;
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #722ed1;
padding-bottom: 20px;
}
.logo {
color: #722ed1;
font-size: 24px;
font-weight: bold;
}
.badge {
background: linear-gradient(135deg, #722ed1, #531dab);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
display: inline-block;
margin-top: 10px;
}
.content {
background-color: white;
padding: 25px;
border-radius: 6px;
margin-bottom: 20px;
border-left: 4px solid #722ed1;
}
.plan-box {
text-align: center;
background-color: #f9f0ff;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
border: 1px solid #d3adf7;
}
.plan-name {
font-size: 22px;
font-weight: bold;
color: #531dab;
}
.plan-price {
font-size: 28px;
font-weight: bold;
color: #722ed1;
margin-top: 8px;
}
.plan-frequency {
color: #6c757d;
font-size: 14px;
}
.info-section {
background-color: #f9f0ff;
padding: 15px;
border-radius: 4px;
margin: 20px 0;
border: 1px solid #d3adf7;
}
.info-item {
margin: 8px 0;
}
.info-label {
font-weight: bold;
display: inline-block;
width: 140px;
color: #391085;
}
.info-value {
color: #2c3e50;
}
.btn {
display: inline-block;
padding: 12px 28px;
border-radius: 6px;
font-size: 15px;
font-weight: bold;
text-decoration: none;
background: linear-gradient(135deg, #722ed1, #531dab);
color: white;
}
.footer {
text-align: center;
font-size: 12px;
color: #6c757d;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
}
.app-branding {
color: #722ed1;
font-weight: bold;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">{{ORGANIZATION_NAME}}</div>
<div class="badge">Subscription Confirmed</div>
</div>
<div class="content">
<p>Hi {{RECIPIENT_NAME}},</p>
<p>Welcome! Your subscription has been activated. Here are the details:</p>
<div class="plan-box">
<div class="plan-name">{{PLAN_NAME}}</div>
<div class="plan-price">{{AMOUNT}}</div>
<div class="plan-frequency">{{FREQUENCY}}</div>
</div>
<div class="info-section">
<div class="info-item">
<span class="info-label">Subscription ID:</span>
<span class="info-value">{{SUBSCRIPTION_ID}}</span>
</div>
<div class="info-item">
<span class="info-label">Next Renewal:</span>
<span class="info-value">{{RENEWAL_DATE}}</span>
</div>
</div>
<div style="text-align: center; margin-top: 20px;">
<a href="{{LOGIN_URL}}" class="btn">Go to Dashboard</a>
</div>
<p style="margin-top: 20px;">You can manage your subscription from your account dashboard at any time.</p>
</div>
<div class="footer">
<p>This confirmation was sent by <span class="app-branding">{{ORGANIZATION_NAME}}</span></p>
<p>You can manage or cancel your subscription from your account settings.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,19 @@
{{ORGANIZATION_NAME}} — Subscription Confirmed
Hi {{RECIPIENT_NAME}},
Welcome! Your subscription has been activated.
Subscription Details:
- Plan: {{PLAN_NAME}}
- Amount: {{AMOUNT}} ({{FREQUENCY}})
- Subscription ID: {{SUBSCRIPTION_ID}}
- Next Renewal: {{RENEWAL_DATE}}
Go to your dashboard: {{LOGIN_URL}}
You can manage or cancel your subscription from your account settings at any time.
---
This confirmation was sent by {{ORGANIZATION_NAME}}.