319 lines
17 KiB
TypeScript

import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import compression from 'compression';
import { env } from './config/env';
import { logger } from './utils/logger';
import { prisma } from './config/database';
import { redis } from './config/redis';
import { register, httpRequestDuration, httpRequestsTotal } from './utils/metrics';
import { errorHandler } from './middleware/error-handler';
import { authenticate } from './middleware/auth.middleware';
import { requireRole } from './middleware/rbac.middleware';
import { globalRateLimit, healthMetricsRateLimit } from './middleware/rate-limit';
import { authRouter } from './modules/auth/auth.routes';
import { usersRouter } from './modules/users/users.routes';
import { campaignsRouter } from './modules/influence/campaigns/campaigns.routes';
import { campaignEmailsPublicRouter, campaignEmailsAdminRouter } from './modules/influence/campaign-emails/campaign-emails.routes';
import { emailQueueRouter } from './modules/influence/email-queue/email-queue.routes';
import { representativesRouter } from './modules/influence/representatives/representatives.routes';
import { campaignPublicRouter } from './modules/influence/campaigns/campaigns-public.routes';
import { campaignUserRouter } from './modules/influence/campaigns/campaigns-user.routes';
import { campaignModerationRouter } from './modules/influence/campaigns/campaigns-moderation.routes';
import { responseCampaignPublicRouter, responsesPublicRouter, responsesAdminRouter } from './modules/influence/responses/responses.routes';
import { locationsAdminRouter, locationsPublicRouter } from './modules/map/locations/locations.routes';
import { bulkGeocodeRouter } from './modules/map/locations/bulk-geocode.routes';
import { cutsAdminRouter, cutsPublicRouter } from './modules/map/cuts/cuts.routes';
import { shiftsAdminRouter, shiftsPublicRouter, shiftsVolunteerRouter } from './modules/map/shifts/shifts.routes';
import shiftSeriesRouter from './modules/map/shifts/shift-series.routes';
import { mapSettingsRouter } from './modules/map/settings/settings.routes';
import { qrRouter } from './modules/qr/qr.routes';
import { listmonkRouter } from './modules/listmonk/listmonk.routes';
import { listmonkWebhookRouter } from './modules/listmonk/listmonk-webhook.routes';
import { pagesPublicRouter } from './modules/pages/pages-public.routes';
import { pagesAdminRouter } from './modules/pages/pages-admin.routes';
import { blocksRouter } from './modules/pages/blocks.routes';
import { docsRouter } from './modules/docs/docs.routes';
import { servicesRouter } from './modules/services/services.routes';
import { siteSettingsRouter } from './modules/settings/settings.routes';
import { canvassVolunteerRouter, canvassAdminRouter } from './modules/map/canvass/canvass.routes';
import { canvassExportRouter } from './modules/map/canvass/canvass-export.routes';
import { trackingVolunteerRouter, trackingAdminRouter } from './modules/map/tracking/tracking.routes';
import { geocodingRouter } from './modules/map/geocoding/geocoding.routes';
import { pangolinRouter } from './modules/pangolin/pangolin.routes';
import { rocketchatRouter } from './modules/rocketchat/rocketchat.routes';
import { rocketchatWebhookService } from './services/rocketchat-webhook.service';
import { narImportRouter } from './modules/map/locations/nar-import.routes';
import { areaImportRouter } from './modules/map/locations/area-import.routes';
import emailTemplatesRouter from './modules/email-templates/email-templates-admin.routes';
import { observabilityRouter } from './modules/observability/observability.routes';
import { dashboardRouter } from './modules/dashboard/dashboard.routes';
import { initEncryption } from './utils/crypto';
import { emailService } from './services/email.service';
import { emailQueueService } from './services/email-queue.service';
import { notificationQueueService } from './services/notification-queue.service';
import { geocodeQueueService } from './services/geocode-queue.service';
import { startProxy, stopProxy } from './services/listmonk-proxy.service';
import { pagesService } from './modules/pages/pages.service';
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 { volunteerInviteRouter } from './modules/volunteer-invite/volunteer-invite.routes';
import { docsAnalyticsService } from './modules/docs-analytics/docs-analytics.service';
const app = express();
// Trust proxy for reverse proxy (nginx adds X-Forwarded-For)
app.set('trust proxy', 1);
// --- Middleware Stack ---
app.use(helmet({
contentSecurityPolicy: env.NODE_ENV === 'production' ? undefined : false,
}));
app.use(cors({
origin: env.CORS_ORIGINS.split(',').map(s => s.trim()),
credentials: true,
}));
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);
// Request metrics
app.use((req, res, next) => {
const end = httpRequestDuration.startTimer();
res.on('finish', () => {
const route = req.route?.path || req.path;
const labels = { method: req.method, route, status_code: res.statusCode.toString() };
end(labels);
httpRequestsTotal.inc(labels);
});
next();
});
// --- Health Check ---
app.get('/api/health', healthMetricsRateLimit, async (_req, res) => {
const checks: Record<string, string> = {};
try {
await prisma.$queryRaw`SELECT 1`;
checks.database = 'ok';
} catch {
checks.database = 'error';
}
try {
await redis.ping();
checks.redis = 'ok';
} catch {
checks.redis = 'error';
}
const healthy = Object.values(checks).every(v => v === 'ok');
res.status(healthy ? 200 : 503).json({ status: healthy ? 'healthy' : 'degraded', checks });
});
// --- Metrics Endpoint (authenticated - SUPER_ADMIN only) ---
app.get('/api/metrics', authenticate, requireRole('SUPER_ADMIN'), healthMetricsRateLimit, async (_req, res) => {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
});
// --- API Routes ---
app.use('/api/auth', authRouter);
app.use('/api/users', usersRouter);
app.use('/api/campaigns', campaignPublicRouter); // Public campaign details (no auth)
app.use('/api/campaigns', responseCampaignPublicRouter); // Public response routes (no auth)
app.use('/api/campaigns', campaignEmailsPublicRouter); // Public email routes (no auth)
app.use('/api/campaigns', campaignUserRouter); // User campaign submission (auth, non-temp)
app.use('/api/campaigns', campaignModerationRouter); // Moderation queue (admin auth)
app.use('/api/campaigns', campaignEmailsAdminRouter); // Admin email routes (auth required)
app.use('/api/campaigns', campaignsRouter); // Admin campaign CRUD (auth required)
app.use('/api/responses', responsesPublicRouter); // Public response actions (no auth)
app.use('/api/responses', responsesAdminRouter); // Admin response moderation (auth required)
app.use('/api/email-queue', emailQueueRouter);
app.use('/api/representatives', representativesRouter);
app.use('/api/map/locations', locationsPublicRouter); // Public map data (no auth)
app.use('/api/map/locations', locationsAdminRouter); // Admin location CRUD (auth required)
app.use('/api/map/locations/bulk-geocode', bulkGeocodeRouter); // Bulk re-geocoding (admin auth required)
app.use('/api/map/nar-import', narImportRouter); // NAR server-side import (MAP_ADMIN+)
app.use('/api/map/area-import', areaImportRouter); // Area import from multiple sources (MAP_ADMIN+)
app.use('/api/map/cuts', cutsPublicRouter); // Public cut polygons (no auth)
app.use('/api/map/cuts', cutsAdminRouter); // Admin cut CRUD (auth required)
app.use('/api/map/shifts', shiftsPublicRouter); // Public shift listing + signup (no auth)
app.use('/api/map/shifts', shiftsVolunteerRouter); // Volunteer shift signup (auth required)
app.use('/api/map/shifts/series', shiftSeriesRouter); // Shift series management (MAP_ADMIN+)
app.use('/api/map/shifts', shiftsAdminRouter); // Admin shift CRUD (auth required)
app.use('/api/map/geocoding', geocodingRouter); // Geocoding search (MAP_ADMIN+)
app.use('/api/map/settings', mapSettingsRouter); // Map settings (public GET, auth PUT)
app.use('/api/qr', qrRouter); // QR code generation (public)
app.use('/api/listmonk', listmonkWebhookRouter); // Listmonk webhook (shared secret, no JWT)
app.use('/api/listmonk', listmonkRouter); // Listmonk newsletter sync (SUPER_ADMIN)
app.use('/api/email-templates', emailTemplatesRouter); // Email template management (ADMIN roles)
app.use('/api/pages', pagesPublicRouter); // Public landing pages (no auth)
app.use('/api/pages', pagesAdminRouter); // Admin landing page CRUD (auth required)
app.use('/api/page-blocks', blocksRouter); // Admin page block library (auth required)
app.use('/api/docs', docsRouter); // Docs status + config (auth required)
app.use('/api/services', servicesRouter); // Platform services status (SUPER_ADMIN)
app.use('/api/map/canvass', canvassVolunteerRouter); // Volunteer canvass routes (auth required)
app.use('/api/map/canvass', canvassAdminRouter); // Admin canvass routes (MAP_ADMIN+)
app.use('/api/map/canvass', canvassExportRouter); // Canvass-to-campaign export (admin roles)
app.use('/api/map/tracking', trackingVolunteerRouter); // Volunteer GPS tracking (auth required)
app.use('/api/map/tracking', trackingAdminRouter); // Admin GPS tracking (MAP_ADMIN+)
app.use('/api/settings', siteSettingsRouter); // Site settings (public GET, SUPER_ADMIN PUT)
app.use('/api/pangolin', pangolinRouter); // Pangolin tunnel management (SUPER_ADMIN)
app.use('/api/rocketchat', rocketchatRouter); // Rocket.Chat SSO + status (auth required)
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)
app.use('/api/volunteer-invite', volunteerInviteRouter); // Quick join invite (admin generate + public redeem)
// --- Error Handler (must be last) ---
app.use(errorHandler);
// --- Start Server ---
async function start() {
try {
await prisma.$connect();
logger.info('Database connected');
// Initialize encryption for DB-stored secrets (SMTP password, etc.)
// In production, require separate encryption key (not JWT secret reuse)
if (env.NODE_ENV === 'production' && !env.ENCRYPTION_KEY) {
throw new Error('ENCRYPTION_KEY must be set in production (do not reuse JWT_ACCESS_SECRET)');
}
if (!env.ENCRYPTION_KEY) {
logger.warn('ENCRYPTION_KEY not set — falling back to JWT_ACCESS_SECRET for encryption. Set ENCRYPTION_KEY in production.');
}
initEncryption(env.ENCRYPTION_KEY || env.JWT_ACCESS_SECRET);
// Warn if Listmonk sync is enabled but webhook secret is not configured
if (env.LISTMONK_SYNC_ENABLED === 'true' && !env.LISTMONK_WEBHOOK_SECRET) {
logger.warn('LISTMONK_SYNC_ENABLED is true but LISTMONK_WEBHOOK_SECRET is not set. Unsubscribe events from Listmonk will not be processed.');
}
// Rebuild SMTP transporter from DB settings (env fallback for empty fields)
await emailService.rebuildTransporter();
emailQueueService.startWorker();
notificationQueueService.startWorker();
geocodeQueueService.startWorker();
startProxy();
// Clean expired verification/reset tokens on startup + hourly
verificationTokenService.cleanupExpiredTokens().catch(() => {});
passwordResetTokenService.cleanupExpiredTokens().catch(() => {});
setInterval(() => {
verificationTokenService.cleanupExpiredTokens().catch(() => {});
passwordResetTokenService.cleanupExpiredTokens().catch(() => {});
}, 60 * 60 * 1000);
// Close abandoned canvass sessions on startup + hourly
canvassService.closeAbandonedSessions().catch(() => {});
setInterval(() => {
canvassService.closeAbandonedSessions().catch(() => {});
}, 60 * 60 * 1000);
// Clean old tracking data on startup + daily
trackingService.cleanupOldData(30).catch(() => {});
setInterval(() => trackingService.cleanupOldData(30).catch(() => {}), 24 * 60 * 60 * 1000);
// Close stale tracking sessions (no data for 2h) — hourly
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);
// Setup Rocket.Chat notification channels (non-blocking)
rocketchatWebhookService.setupChannels().catch(() => {});
// Sync MkDocs overrides on startup
pagesService.syncOverrides()
.then(({ imported, updated }) => {
if (imported > 0 || updated > 0) {
logger.info(`Startup sync: imported ${imported}, updated ${updated} MkDocs overrides`);
}
})
.catch((err) => {
logger.warn('Startup sync of MkDocs overrides failed:', err);
});
// Validate MkDocs exports on startup
pagesService.validateExports()
.then(({ validated, repaired, errors }) => {
if (repaired > 0 || errors.length > 0) {
logger.info(`Validation: ${validated} checked, ${repaired} repaired, ${errors.length} errors`);
}
})
.catch((err) => logger.warn('Validation failed:', err));
// Schedule daily validation
setInterval(() => {
pagesService.validateExports().catch((err) => {
logger.warn('Scheduled validation failed:', err);
});
}, 24 * 60 * 60 * 1000);
app.listen(env.PORT, () => {
logger.info(`API server running on port ${env.PORT} [${env.NODE_ENV}]`);
});
} catch (err) {
logger.error('Failed to start server:', err);
process.exit(1);
}
}
start();
// Graceful shutdown
for (const signal of ['SIGTERM', 'SIGINT']) {
process.on(signal, async () => {
logger.info(`${signal} received, shutting down...`);
await stopProxy();
await emailQueueService.close();
await notificationQueueService.close();
await geocodeQueueService.close();
await prisma.$disconnect();
redis.disconnect();
process.exit(0);
});
}
export { app };