const path = require('path'); const dotenv = require('dotenv'); // Load environment variables dotenv.config(); // Error monitoring — a no-op if SENTRY_DSN isn't set, so this is safe in every // environment (dev, a fresh deploy that hasn't configured Sentry yet, etc.). // Must run before express/@prisma/client are required below — Sentry's // auto-instrumentation patches those modules via a require hook, which only // works if Sentry.init() runs before they're first required into the cache. if (process.env.SENTRY_DSN) { const Sentry = require('@sentry/node'); Sentry.init({ dsn: process.env.SENTRY_DSN, environment: process.env.NODE_ENV || 'development', // Small single-VM deployment, not high-traffic — start conservative and raise // this once real usage is visible in Sentry, rather than sampling every request. tracesSampleRate: 0.1, }); } const express = require('express'); const { version: API_VERSION } = require('../package.json'); const cors = require('cors'); const rateLimit = require('express-rate-limit'); const { PrismaClient } = require('@prisma/client'); const { notFound, errorHandler } = require('./middleware/errorMiddleware'); const getRawBody = require('raw-body'); // Initialize Prisma client const prisma = new PrismaClient(); // Initialize Express app const app = express(); const PORT = process.env.PORT || 3000; // Trust proxy — required when running behind a reverse proxy (e.g. nginx on a // separate server) so req.ip / X-Forwarded-For are read correctly by // express-rate-limit and friends. Accepts a hop count ("1"), "true"/"false", // or a comma-separated list of trusted proxy IPs/CIDRs. if (process.env.TRUST_PROXY) { const raw = process.env.TRUST_PROXY.trim(); let trustProxyValue; if (raw === 'true') trustProxyValue = true; else if (raw === 'false') trustProxyValue = false; else if (/^\d+$/.test(raw)) trustProxyValue = parseInt(raw, 10); else if (raw.includes(',')) trustProxyValue = raw.split(',').map((s) => s.trim()); else trustProxyValue = raw; app.set('trust proxy', trustProxyValue); console.log(`[startup] trust proxy set to: ${JSON.stringify(trustProxyValue)}`); } // CORS — allow only the configured frontend origin const allowedOrigins = (process.env.FRONTEND_URL || 'http://localhost:3000') .split(',') .map((o) => o.trim()); const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV; const isTesting = process.env.NODE_ENV === 'testing'; const isProd = process.env.NODE_ENV === 'production'; const modeLabel = isProd ? 'production' : isTesting ? 'testing' : 'development'; console.log(`Running in ${modeLabel} mode.`); // In testing or production, require all critical env vars to be set at startup if (isTesting || isProd) { const required = ['DATABASE_URL', 'JWT_SECRET']; const missing = required.filter(k => !process.env[k]); if (missing.length > 0) { console.error(`[startup] Missing required environment variables for ${modeLabel} mode: ${missing.join(', ')}`); process.exit(1); } } app.use(cors({ origin: (origin, callback) => { // Allow requests with no origin (e.g. mobile apps, curl, Postman) if (!origin) return callback(null, true); // Always allow configured origins if (allowedOrigins.includes(origin)) return callback(null, true); // In dev or testing, also allow any LAN origin on the same port (e.g. phone on 192.168.x.x:3000) if (isDev || isTesting) { try { const u = new URL(origin); const isLan = /^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(u.hostname); if (isLan) return callback(null, true); } catch {} } callback(new Error(`CORS: origin '${origin}' not allowed`)); }, credentials: true, })); // Global rate limiter — 5000 requests per 5 minutes per IP // Webhooks are excluded because they come from Yoco's servers and are already HMAC-verified const globalLimiter = rateLimit({ windowMs: 5 * 60 * 1000, max: 5000, standardHeaders: true, legacyHeaders: false, message: { message: 'Too many requests, please try again later.' }, skip: (req) => req.path.startsWith('/api/webhooks'), }); app.use(globalLimiter); // Raw body parsing middleware for webhooks app.use((req, res, next) => { if (req.path.startsWith('/api/webhooks')) { getRawBody(req, { length: req.headers['content-length'], encoding: 'utf-8' }, (err, rawBody) => { if (err) return next(err); req.rawBody = rawBody; next(); }); } else { next(); } }); // Import routes const userRoutes = require('./routes/userRoutes'); const eventRoutes = require('./routes/eventRoutes'); const registrationRoutes = require('./routes/registrationRoutes'); const paymentRoutes = require('./routes/paymentRoutes'); const ticketRoutes = require('./routes/ticketRoutes'); const webhookRoutes = require('./routes/webhookRoutes'); const uploadRoutes = require('./routes/uploadRoutes'); const reportRoutes = require('./routes/reportRoutes'); const formRoutes = require('./routes/formRoutes'); const yocoTransactionRoutes = require('./routes/yocoTransactionRoutes'); const automationRoutes = require('./routes/automationRoutes'); const broadcastRoutes = require('./routes/broadcastRoutes'); const whatsappBroadcastRoutes = require('./routes/whatsappBroadcastRoutes'); const scheduledEmailRoutes = require('./routes/scheduledEmailRoutes'); const sectionRoutes = require('./routes/sectionRoutes'); const bannerRoutes = require('./routes/bannerRoutes'); const whatsappRoutes = require('./routes/whatsappRoutes'); const settingsRoutes = require('./routes/settingsRoutes'); const setupRoutes = require('./routes/setupRoutes'); const costRoutes = require('./routes/costRoutes'); const cashupRoutes = require('./routes/cashupRoutes'); const statsRoutes = require('./routes/statsRoutes'); const adminRoutes = require('./routes/adminRoutes'); const backupRoutes = require('./routes/backupRoutes'); // Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream app.use('/api/webhooks', webhookRoutes); // Apply JSON body parser for all non-webhook routes app.use(express.json()); // Use routes app.use('/api/users', userRoutes); app.use('/api/events', eventRoutes); app.use('/api/registrations', registrationRoutes); app.use('/api/payments', paymentRoutes); app.use('/api/tickets', ticketRoutes); app.use('/api/uploads', uploadRoutes); app.use('/api/reports', reportRoutes); app.use('/api/forms', formRoutes); app.use('/api/yoco-transactions', yocoTransactionRoutes); app.use('/api/automations', automationRoutes); app.use('/api/broadcasts', broadcastRoutes); app.use('/api/whatsapp-broadcasts', whatsappBroadcastRoutes); app.use('/api/scheduled-emails', scheduledEmailRoutes); app.use('/api/sections', sectionRoutes); app.use('/api/banner', bannerRoutes); app.use('/api/whatsapp', whatsappRoutes); app.use('/api/settings', settingsRoutes); app.use('/api/setup', setupRoutes); app.use('/api/stats', statsRoutes); app.use('/api', costRoutes); app.use('/api/cashups', cashupRoutes); app.use('/api/admin', adminRoutes); app.use('/api/backups', backupRoutes); // Pre-warm the settings cache so synchronous helpers have DB values from startup const { getSettingSync, warmCache } = require('./utils/settingsCache'); warmCache().catch(() => {}); // Uploaded branding assets can include SVGs, which may embed `; res.send(html); }); // Error middleware app.use(notFound); // Sentry captures the error here, then passes it through unchanged — errorHandler // below remains the sole source of what's actually sent back to the client. if (process.env.SENTRY_DSN) { const Sentry = require('@sentry/node'); Sentry.setupExpressErrorHandler(app); } app.use(errorHandler); // Start server app.listen(PORT, () => { console.log(`Server is running at http://localhost:${PORT}`); // Attempt a one-time background sync of attachment manifests into DB try { const { syncManifestsToDb } = require('./utils/attachmentsSync'); const prismaShared = require('./config/db'); setTimeout(async () => { try { const summary = await syncManifestsToDb(prismaShared, { dryRun: false }); if ((summary.imported || 0) > 0) { console.log(`[attachments sync] Imported ${summary.imported} attachments from manifests (skipped ${summary.skipped}).`); } else { console.log('[attachments sync] No attachments imported (either none found or already in DB).'); } } catch (e) { console.warn('[attachments sync] Failed:', e?.message || e); } }, 1000); } catch (e) { console.warn('[attachments sync] Not scheduled:', e?.message || e); } // Schedule daily summaries at 07:00 local time (configurable) try { const enabled = String(process.env.DAILY_SUMMARY_ENABLED || 'true').toLowerCase() !== 'false'; if (enabled) { const { sendDailyEventSummaries } = require('./utils/notifications'); function msUntilNext(hour, minute) { const now = new Date(); const next = new Date(now); next.setHours(hour, minute, 0, 0); if (next <= now) { next.setDate(next.getDate() + 1); } return next.getTime() - now.getTime(); } async function scheduleNext() { const delay = msUntilNext(7, 0); setTimeout(async () => { try { console.log('[daily summaries] Running daily event summaries...'); await sendDailyEventSummaries(new Date()); console.log('[daily summaries] Completed sending daily event summaries.'); } catch (e) { console.error('[daily summaries] Failed:', e?.message || e); } finally { // Schedule next run scheduleNext(); } }, delay); } scheduleNext(); console.log('[daily summaries] Scheduler initialized (07:00 local time). Set DAILY_SUMMARY_ENABLED=false to disable.'); } else { console.log('[daily summaries] Scheduler disabled by env DAILY_SUMMARY_ENABLED=false'); } } catch (e) { console.warn('[daily summaries] Not scheduled:', e?.message || e); } // Daily temp-file cleanup — removes files older than 7 days from /temp try { const { cleanupTempFiles } = require('./utils/cleanupTemp'); function scheduleTempCleanup() { const now = new Date(); const next = new Date(now); next.setHours(3, 0, 0, 0); // Run at 03:00 local time (low-traffic window) if (next <= now) next.setDate(next.getDate() + 1); setTimeout(() => { try { const result = cleanupTempFiles(); if (result.deleted > 0 || result.errors > 0) { console.log(`[temp cleanup] Deleted ${result.deleted} file(s), ${result.errors} error(s).`); } } catch (e) { console.warn('[temp cleanup] Failed:', e?.message || e); } finally { scheduleTempCleanup(); } }, next.getTime() - now.getTime()); } scheduleTempCleanup(); console.log('[temp cleanup] Scheduler initialized (03:00 local time, files older than 7 days).'); } catch (e) { console.warn('[temp cleanup] Not scheduled:', e?.message || e); } // Nightly database backup at 02:00 local time (before the 03:00 temp cleanup) try { const enabled = String(process.env.BACKUP_ENABLED || 'true').toLowerCase() !== 'false'; if (enabled) { const { runBackup } = require('./utils/backupUtils'); function scheduleNightlyBackup() { const now = new Date(); const next = new Date(now); next.setHours(2, 0, 0, 0); if (next <= now) next.setDate(next.getDate() + 1); setTimeout(async () => { try { const result = await runBackup(); console.log(`[backup] Completed: ${result.filename}`); } catch (e) { console.error('[backup] Failed:', e?.message || e); } finally { scheduleNightlyBackup(); } }, next.getTime() - now.getTime()); } scheduleNightlyBackup(); console.log('[backup] Scheduler initialized (02:00 local time). Set BACKUP_ENABLED=false to disable.'); } else { console.log('[backup] Scheduler disabled by env BACKUP_ENABLED=false'); } } catch (e) { console.warn('[backup] Not scheduled:', e?.message || e); } // Scheduled emails worker (polling) try { const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false'; if (enabled) { const { getDueJobs, updateJob, purgeSentJobs } = require('./utils/scheduledEmails'); const { emailEventAttendees, whatsappEventAttendees } = require('./controllers/eventController'); const { sendBroadcast } = require('./controllers/broadcastController'); const { sendWhatsAppBroadcast } = require('./controllers/whatsappBroadcastController'); const intervalMs = parseInt(process.env.SCHEDULED_EMAILS_INTERVAL_MS || '30000', 10); setInterval(async () => { try { try { purgeSentJobs(24 * 60 * 60 * 1000); } catch (e) { console.warn('[scheduled emails] purge failed:', e?.message || e); } const due = getDueJobs(new Date()); if (!due || due.length === 0) return; for (const job of due) { try { updateJob(job.id, { status: 'sending', attempts: (job.attempts || 0) + 1, lastError: null }); let responseBody = null; let code = 200; const mockRes = { status: (c) => { code = c; return mockRes; }, json: (b) => { responseBody = b; return mockRes; } }; if (job.broadcast && job.channel === 'whatsapp') { const mockReq = { body: job.payload }; await sendWhatsAppBroadcast(mockReq, mockRes); } else if (job.broadcast) { const mockReq = { body: job.payload }; await sendBroadcast(mockReq, mockRes); } else if (job.channel === 'whatsapp') { const mockReq = { params: { id: job.eventId }, body: job.payload }; await whatsappEventAttendees(mockReq, mockRes); } else { const mockReq = { params: { id: job.eventId }, body: job.payload }; await emailEventAttendees(mockReq, mockRes); } if (code >= 200 && code < 300) { updateJob(job.id, { status: 'sent', sentAt: new Date().toISOString(), lastResult: responseBody || null }); } else { updateJob(job.id, { status: 'error', lastError: (responseBody && responseBody.message) ? responseBody.message : `HTTP ${code}` }); } } catch (e) { updateJob(job.id, { status: 'error', lastError: String(e?.message || e) }); } } } catch (e) { console.warn('[scheduled emails] poll failed:', e?.message || e); } }, isNaN(intervalMs) ? 30000 : intervalMs); console.log('[scheduled emails] Worker initialized. Set SCHEDULED_EMAILS_ENABLED=false to disable.'); } else { console.log('[scheduled emails] Worker disabled by env SCHEDULED_EMAILS_ENABLED=false'); } } catch (e) { console.warn('[scheduled emails] Worker not started:', e?.message || e); } }); // Log unhandled promise rejections without killing the server, since a single // missed .catch() on fire-and-forget notification code (email/WhatsApp sends) // would otherwise take the whole app down. process.on('unhandledRejection', (err) => { console.error('UNHANDLED REJECTION!', err?.name, err?.message); console.error(err?.stack || err); if (process.env.SENTRY_DSN) { try { require('@sentry/node').captureException(err); } catch {} } }); module.exports = { app, prisma };