Tonne of udpatess
This commit is contained in:
@@ -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
13
api/docker-entrypoint.sh
Executable 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
17
api/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
64
api/src/modules/docs-analytics/docs-analytics.routes.ts
Normal file
64
api/src/modules/docs-analytics/docs-analytics.routes.ts
Normal 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 });
|
||||
});
|
||||
11
api/src/modules/docs-analytics/docs-analytics.schemas.ts
Normal file
11
api/src/modules/docs-analytics/docs-analytics.schemas.ts
Normal 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),
|
||||
});
|
||||
140
api/src/modules/docs-analytics/docs-analytics.service.ts
Normal file
140
api/src/modules/docs-analytics/docs-analytics.service.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
@@ -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([
|
||||
|
||||
29
api/src/modules/docs/header-builder.schemas.ts
Normal file
29
api/src/modules/docs/header-builder.schemas.ts
Normal 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>;
|
||||
240
api/src/modules/docs/header-builder.service.ts
Normal file
240
api/src/modules/docs/header-builder.service.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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();
|
||||
125
api/src/modules/gallery-ads/gallery-ads-admin.routes.ts
Normal file
125
api/src/modules/gallery-ads/gallery-ads-admin.routes.ts
Normal 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 };
|
||||
64
api/src/modules/gallery-ads/gallery-ads-public.routes.ts
Normal file
64
api/src/modules/gallery-ads/gallery-ads-public.routes.ts
Normal 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 };
|
||||
48
api/src/modules/gallery-ads/gallery-ads.schemas.ts
Normal file
48
api/src/modules/gallery-ads/gallery-ads.schemas.ts
Normal 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>;
|
||||
298
api/src/modules/gallery-ads/gallery-ads.service.ts
Normal file
298
api/src/modules/gallery-ads/gallery-ads.service.ts
Normal 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();
|
||||
@@ -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 };
|
||||
@@ -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>;
|
||||
459
api/src/modules/influence/effectiveness/effectiveness.service.ts
Normal file
459
api/src/modules/influence/effectiveness/effectiveness.service.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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>;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
scheduledUnpublishAt: true,
|
||||
category: true,
|
||||
isShort: true,
|
||||
accessLevel: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
|
||||
180
api/src/modules/payments/donations.service.ts
Normal file
180
api/src/modules/payments/donations.service.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
168
api/src/modules/payments/payment-email.service.ts
Normal file
168
api/src/modules/payments/payment-email.service.ts
Normal 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';
|
||||
}
|
||||
},
|
||||
};
|
||||
71
api/src/modules/payments/payment-settings.service.ts
Normal file
71
api/src/modules/payments/payment-settings.service.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
369
api/src/modules/payments/payments-admin.routes.ts
Normal file
369
api/src/modules/payments/payments-admin.routes.ts
Normal 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 };
|
||||
149
api/src/modules/payments/payments-public.routes.ts
Normal file
149
api/src/modules/payments/payments-public.routes.ts
Normal 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 };
|
||||
100
api/src/modules/payments/payments.schemas.ts
Normal file
100
api/src/modules/payments/payments.schemas.ts
Normal 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(),
|
||||
});
|
||||
325
api/src/modules/payments/products.service.ts
Normal file
325
api/src/modules/payments/products.service.ts
Normal 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' },
|
||||
});
|
||||
},
|
||||
};
|
||||
329
api/src/modules/payments/subscriptions.service.ts
Normal file
329
api/src/modules/payments/subscriptions.service.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
358
api/src/modules/payments/webhook.service.ts
Normal file
358
api/src/modules/payments/webhook.service.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
44
api/src/services/stripe.client.ts
Normal file
44
api/src/services/stripe.client.ts
Normal 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);
|
||||
}
|
||||
150
api/src/templates/email/donation-receipt.html
Normal file
150
api/src/templates/email/donation-receipt.html
Normal 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>
|
||||
26
api/src/templates/email/donation-receipt.txt
Normal file
26
api/src/templates/email/donation-receipt.txt
Normal 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.
|
||||
149
api/src/templates/email/product-receipt.html
Normal file
149
api/src/templates/email/product-receipt.html
Normal 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>
|
||||
19
api/src/templates/email/product-receipt.txt
Normal file
19
api/src/templates/email/product-receipt.txt
Normal 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.
|
||||
156
api/src/templates/email/subscription-welcome.html
Normal file
156
api/src/templates/email/subscription-welcome.html
Normal 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>
|
||||
19
api/src/templates/email/subscription-welcome.txt
Normal file
19
api/src/templates/email/subscription-welcome.txt
Normal 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}}.
|
||||
Reference in New Issue
Block a user