const express = require('express'); const path = require('path'); const { version: API_VERSION } = require('../package.json'); const cors = require('cors'); const rateLimit = require('express-rate-limit'); const dotenv = require('dotenv'); const { PrismaClient } = require('@prisma/client'); const { notFound, errorHandler } = require('./middleware/errorMiddleware'); const getRawBody = require('raw-body'); // Load environment variables dotenv.config(); // Initialize Prisma client const prisma = new PrismaClient(); // Initialize Express app const app = express(); const PORT = process.env.PORT || 3000; // 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'); // 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); // Pre-warm the settings cache so synchronous helpers have DB values from startup require('./utils/settingsCache').warmCache().catch(() => {}); app.use('/uploads', express.static('public/uploads')); // ── Shared page helpers ──────────────────────────────────────────────────────── const jwt = require('jsonwebtoken'); function pageShell(title, accentColor, bodyHtml) { return ` ${title}
${bodyHtml}
`; } function formatUptime(seconds) { const d = Math.floor(seconds / 86400); const h = Math.floor((seconds % 86400) / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); if (d > 0) return `${d}d ${h}h ${m}m`; if (h > 0) return `${h}h ${m}m ${s}s`; if (m > 0) return `${m}m ${s}s`; return `${s}s`; } function formatBytes(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1048576).toFixed(1)} MB`; } // ── Favicon ────────────────────────────────────────────────────────────────── app.get('/favicon.ico', (req, res) => { res.sendFile(path.join(__dirname, 'favicon.ico')); }); // ── Root route — status page ──────────────────────────────────────────────── app.get('/', async (req, res) => { const mem = process.memoryUsage(); const uptimeSec = process.uptime(); const now = new Date().toISOString(); // Database health check let dbStatus = 'ok'; let dbMsg = 'Connected'; try { await prisma.$queryRaw`SELECT 1`; } catch (e) { dbStatus = 'error'; dbMsg = 'Unreachable'; } const dbBadge = dbStatus === 'ok' ? `✓ ${dbMsg}` : `✗ ${dbMsg}`; const envBadge = isProd ? `production` : isTesting ? `testing` : `development`; const html = pageShell('Hope Events API — Status', '#2563eb', `

Hope Events API

v${API_VERSION} — ${now}

Status
✓ Online
Environment
${envBadge}
Database
${dbBadge}
Uptime
${formatUptime(uptimeSec)}
Heap Used
${formatBytes(mem.heapUsed)}
Heap Total
${formatBytes(mem.heapTotal)}
RSS
${formatBytes(mem.rss)}
Node
${process.version}
API documentation is available at /docs — requires an admin account token.
`); res.send(html); }); // ── /docs route — interactive API reference, admin only ───────────────────── app.get('/docs', async (req, res) => { const tokenFromQuery = req.query.token; const tokenFromHeader = req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.split(' ')[1] : null; const token = tokenFromQuery || tokenFromHeader; if (!token) { return res.status(401).send(pageShell('API Docs — Login Required', '#2563eb', `

API Documentation

Admin access required

Pass your admin JWT to view the docs:

/docs?token=<your-admin-jwt>

Copy your token from the browser's localStorage key token after logging in as admin.

`)); } let user; try { const decoded = jwt.verify(token, process.env.JWT_SECRET); user = await prisma.user.findUnique({ where: { id: decoded.id }, select: { id: true, name: true, email: true, role: true, isActive: true, tokenVersion: true }, }); if (!user || !user.isActive) throw new Error('User not found or inactive'); if ((decoded.tokenVersion ?? 0) !== user.tokenVersion) throw new Error('Token revoked'); if (user.role !== 'admin') throw new Error('Admin role required'); } catch (e) { return res.status(403).send(pageShell('API Docs — Access Denied', '#dc2626', `

Access Denied

Admin role required

${e.message}

Make sure you are logged in as an admin and using a valid, non-expired token.

`)); } // ── Endpoint data ──────────────────────────────────────────────────────── const GROUPS = [ { title: 'Users', base: '/api/users', endpoints: [ { method:'POST', path:'/api/users', auth:'public', desc:'Register a new account', request:{ body:{ name:'Jane Doe', email:'jane@example.com', password:'secret123', phoneNumber:'+27821234567' }}, responses:[ { status:201, desc:'Created', body:{ id:'a1b2c3d4-0000-0000-0000-000000000001', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...' }}, { status:400, desc:'Validation error', body:{ message:'Email already in use' }}, ]}, { method:'POST', path:'/api/users/login', auth:'public', desc:'Login — returns a signed JWT', notes:'Rate-limited to 15 requests/min per IP.', request:{ body:{ email:'jane@example.com', password:'secret123' }}, responses:[ { status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...' }}, { status:401, desc:'Wrong credentials', body:{ message:'Invalid email or password' }}, ]}, { method:'POST', path:'/api/users/forgot', auth:'public', desc:'Request a password reset email', request:{ body:{ email:'jane@example.com' }}, responses:[ { status:200, desc:'Always succeeds (no user enumeration)', body:{ message:'If that email exists, a reset link has been sent.' }}, ]}, { method:'POST', path:'/api/users/reset', auth:'public', desc:'Reset password using token from email', request:{ body:{ token:'reset-token-from-email', password:'newPassword123' }}, responses:[ { status:200, desc:'Success', body:{ message:'Password reset successful.' }}, { status:400, desc:'Invalid or expired token', body:{ message:'Reset token is invalid or has expired.' }}, ]}, { method:'POST', path:'/api/users/activate', auth:'public', desc:'Activate a new account via email link', request:{ body:{ token:'activation-token-from-email' }}, responses:[ { status:200, desc:'Success', body:{ message:'Account activated.' }}, { status:400, desc:'Invalid token', body:{ message:'Invalid or expired activation token.' }}, ]}, { method:'GET', path:'/api/users/profile', auth:'user+', desc:'Get own profile', responses:[ { status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', phoneNumber:'+27821234567', notificationPreference:'email', createdAt:'2024-01-15T08:00:00.000Z' }}, { status:401, desc:'Unauthorized', body:{ message:'Not authorized, no token' }}, ]}, { method:'PUT', path:'/api/users/profile', auth:'user+', desc:'Update own profile', request:{ body:{ name:'Jane Smith', phoneNumber:'+27821234567', notificationPreference:'both' }}, responses:[ { status:200, desc:'Updated', body:{ id:'a1b2c3d4-...', name:'Jane Smith', email:'jane@example.com', notificationPreference:'both' }}, ]}, { method:'POST', path:'/api/users/revoke-sessions', auth:'user+', desc:'Invalidate all own sessions by incrementing tokenVersion', responses:[{ status:200, desc:'Success', body:{ message:'All sessions revoked.' }}]}, { method:'POST', path:'/api/users/close-account', auth:'user+', desc:'Deactivate own account', responses:[{ status:200, desc:'Success', body:{ message:'Account deactivated.' }}]}, { method:'GET', path:'/api/users', auth:'supervisor+', desc:'List all users (paginated). All filters are applied server-side.', queryParams:{ page:'Page number (default 1)', limit:'Items per page (max 200, default 100)', search:'Search by name, email, or phone number (case-insensitive substring)', role:'Filter by role: admin|supervisor|staff|user', isActive:'Filter by active status: true|false (omit for both)' }, responses:[ { status:200, desc:'Success', body:{ data:[{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', isActive:true, createdAt:'2024-01-15T08:00:00.000Z' }], total:1, page:1, limit:20 }}, ]}, { method:'GET', path:'/api/users/check-exists', auth:'supervisor+', desc:'Check whether an account already exists for a given email and/or phone number (used by the self-service kiosk and manual registration screens to avoid duplicate accounts)', queryParams:{ email:'Email to look up (optional)', phone:'Phone number to look up (optional; SA formats normalized)' }, responses:[ { status:200, desc:'Success', body:{ exists:true, hasEmail:true, hasPhone:false }}, ]}, { method:'GET', path:'/api/users/:id', auth:'admin', desc:'Get a single user by ID', pathParams:{ ':id':'User UUID' }, responses:[ { status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', isActive:true, phoneNumber:'+27821234567', notificationPreference:'email' }}, { status:404, desc:'Not found', body:{ message:'User not found' }}, ]}, { method:'PUT', path:'/api/users/:id', auth:'admin', desc:'Update any user (including role)', pathParams:{ ':id':'User UUID' }, request:{ body:{ role:'supervisor', isActive:true, name:'Jane Doe' }}, responses:[ { status:200, desc:'Updated', body:{ id:'a1b2c3d4-...', name:'Jane Doe', role:'supervisor' }}, ]}, { method:'DELETE', path:'/api/users/:id', auth:'admin', desc:'Deactivate a user account (sets isActive: false; does not erase data)', pathParams:{ ':id':'User UUID' }, responses:[ { status:200, desc:'Deactivated', body:{ message:'User deactivated' }}, { status:404, desc:'Not found', body:{ message:'User not found' }}, ]}, { method:'POST', path:'/api/users/:id/revoke-sessions', auth:'admin', desc:'Revoke all sessions for another user', pathParams:{ ':id':'User UUID' }, responses:[{ status:200, desc:'Success', body:{ message:'Sessions revoked for user.' }}]}, { method:'POST', path:'/api/users/:id/anonymize', auth:'admin', desc:'Erase personal data for a user — sets name to "Deleted User", email to deleted-{id}@deleted.local, clears phone number, deactivates account, revokes all sessions. Irreversible.', pathParams:{ ':id':'User UUID' }, responses:[ { status:200, desc:'Anonymised', body:{ message:'User data deleted' }}, { status:404, desc:'Not found', body:{ message:'User not found' }}, ]}, ]}, { title: 'Events', base: '/api/events', endpoints: [ { method:'GET', path:'/api/events', auth:'public', desc:'List active, public, upcoming events. Each event includes isSoldOut:boolean — true when every option with a stockLimit > 0 is fully booked.', queryParams:{ limit:'Max results (default 20)' }, responses:[ { status:200, desc:'Success', body:[{ id:'ev-uuid-...', title:'Camp 2025', startDate:'2025-07-10T08:00:00.000Z', endDate:'2025-07-14T17:00:00.000Z', price:450, picture:'/uploads/camp.jpg', isActive:true, isSoldOut:false }]}, ]}, { method:'GET', path:'/api/events/all', auth:'staff+', desc:'All events including hidden and past', queryParams:{ includePast:'Include past events (true|false, default false)' }, responses:[{ status:200, desc:'Success', body:[{ id:'ev-uuid-...', title:'Camp 2025', isHidden:false, isActive:true }]}]}, { method:'GET', path:'/api/events/:id', auth:'public', desc:'Get full event detail by ID. When called by staff/supervisor/admin, the response also includes createdBy and notifyRecipients (who the event\'s notifications go to).', pathParams:{ ':id':'Event UUID' }, responses:[ { status:200, desc:'Success', body:{ id:'ev-uuid-...', title:'Camp 2025', description:'Annual family camp.', startDate:'2025-07-10T08:00:00.000Z', endDate:'2025-07-14T17:00:00.000Z', price:450, requiresAuth:true, eventOptions:[{ id:'opt-uuid-...', name:'Adult', price:450, isMainTicket:true }], createdBy:{ id:'user-uuid-...', name:'Jane Supervisor', email:'jane@example.com' }, notifyRecipients:[]}}, { status:404, desc:'Not found', body:{ message:'Event not found' }}, ]}, { method:'GET', path:'/api/events/by-alias/:redirectUrl', auth:'public', desc:'Get event by its URL alias', pathParams:{ ':redirectUrl':'URL alias string (e.g. camp-2025)' }, responses:[{ status:200, desc:'Success', body:{ id:'ev-uuid-...', title:'Camp 2025', redirectUrl:'camp-2025' }}]}, { method:'POST', path:'/api/events', auth:'supervisor+', desc:'Create a new event', request:{ body:{ title:'Camp 2025', description:'Annual family camp', startDate:'2025-07-10T08:00:00.000Z', endDate:'2025-07-14T17:00:00.000Z', registrationDeadline:'2025-07-01T00:00:00.000Z', price:450, isActive:true, isHidden:false, requiresAuth:true }}, responses:[ { status:201, desc:'Created', body:{ id:'ev-uuid-new', title:'Camp 2025', createdAt:'2024-11-01T09:00:00.000Z' }}, ]}, { method:'PUT', path:'/api/events/:id', auth:'supervisor+', desc:'Update an event', pathParams:{ ':id':'Event UUID' }, request:{ body:{ title:'Camp 2025 Updated', price:500 }}, responses:[{ status:200, desc:'Updated', body:{ id:'ev-uuid-...', title:'Camp 2025 Updated', price:500 }}]}, { method:'GET', path:'/api/events/:id/notify-recipients', auth:'supervisor+', desc:'Get which users receive registration/payment/daily-summary notifications for this event. Lightweight — skips the option/stock computation that GET /api/events/:id does.', pathParams:{ ':id':'Event UUID' }, responses:[{ status:200, desc:'Success', body:[{ id:'user-uuid-1', name:'Jane Supervisor', email:'jane@example.com', role:'supervisor' }]}]}, { method:'PUT', path:'/api/events/:id/notify-recipients', auth:'supervisor+', desc:'Set which users receive registration/payment/daily-summary notifications for this event. Pass an empty array to fall back to the event creator.', pathParams:{ ':id':'Event UUID' }, request:{ body:{ userIds:['user-uuid-1','user-uuid-2'] }}, responses:[ { status:200, desc:'Saved', body:[{ id:'user-uuid-1', name:'Jane Supervisor', email:'jane@example.com', role:'supervisor' },{ id:'user-uuid-2', name:'John Admin', email:'john@example.com', role:'admin' }]}, { status:400, desc:'Bad input', body:{ message:'userIds must be an array' }}, ]}, { method:'DELETE', path:'/api/events/:id', auth:'admin', desc:'Permanently delete an event', pathParams:{ ':id':'Event UUID' }, responses:[{ status:200, desc:'Deleted', body:{ message:'Event deleted.' }}]}, { method:'POST', path:'/api/events/:id/email-attendees', auth:'supervisor+', desc:'Send a bulk email to event attendees. Use dryRun:true to preview recipients without sending.', pathParams:{ ':id':'Event UUID' }, request:{ body:{ subject:'Payment reminder: Camp 2025', text:'Hi {{name}}, you have an outstanding balance of {{balance}}.', filter:{ status:'unpaid', attendeeIds:['user-uuid-1','user-uuid-2'] }, dryRun:false, template:'custom' }}, responses:[ { status:200, desc:'Sent', body:{ matched:15, sent:14, failed:1 }}, { status:200, desc:'Dry run', body:{ matched:15, dryRun:true, recipients:[{ email:'jane@example.com', name:'Jane Doe' }]}}, ]}, { method:'POST', path:'/api/events/:id/email-attendees/schedule', auth:'supervisor+', desc:'Schedule a bulk email to run at a future time', pathParams:{ ':id':'Event UUID' }, request:{ body:{ subject:'Reminder: Camp 2025', text:'Hi {{name}}, see you soon!', scheduledAt:'2025-07-03T09:00:00.000Z', filter:{ status:'paid' }}}, responses:[{ status:201, desc:'Scheduled', body:{ job:{ id:'job-uuid-...', scheduledAt:'2025-07-03T09:00:00.000Z', status:'queued' }}}]}, { method:'POST', path:'/api/events/:id/whatsapp-attendees', auth:'supervisor+', desc:'Send a bulk WhatsApp message to attendees with a phone number. Supports dryRun.', pathParams:{ ':id':'Event UUID' }, request:{ body:{ message:'Hi {{name}}, camp starts in 3 days! 🏕️', filter:{ status:'paid' }, dryRun:false }}, responses:[ { status:200, desc:'Sent', body:{ matched:12, sent:11 }}, ]}, { method:'POST', path:'/api/events/:id/whatsapp-attendees/schedule', auth:'supervisor+', desc:'Schedule a bulk WhatsApp message', pathParams:{ ':id':'Event UUID' }, request:{ body:{ message:'Hi {{name}}, final reminder for camp tomorrow! 🎉', scheduledAt:'2025-07-09T08:00:00.000Z', filter:{ status:'paid' }}}, responses:[{ status:201, desc:'Scheduled', body:{ job:{ id:'job-uuid-...', status:'queued' }}}]}, { method:'POST', path:'/api/events/:id/options', auth:'supervisor+', desc:'Add a ticket option to an event', pathParams:{ ':id':'Event UUID' }, request:{ body:{ name:'Child (under 12)', price:250, isMainTicket:false }}, responses:[{ status:201, desc:'Created', body:{ id:'opt-uuid-new', name:'Child (under 12)', price:250, isMainTicket:false }}]}, { method:'PUT', path:'/api/events/options/:id', auth:'supervisor+', desc:'Update a ticket option', pathParams:{ ':id':'EventOption UUID' }, request:{ body:{ price:300 }}, responses:[{ status:200, desc:'Updated', body:{ id:'opt-uuid-...', price:300 }}]}, { method:'DELETE', path:'/api/events/options/:id', auth:'admin', desc:'Delete a ticket option (fails if registrations exist)', pathParams:{ ':id':'EventOption UUID' }, responses:[ { status:200, desc:'Deleted', body:{ message:'Option deleted.' }}, { status:400, desc:'In use', body:{ message:'Cannot delete option with existing registrations.' }}, ]}, ]}, { title: 'Registrations', base: '/api/registrations', endpoints: [ { method:'POST', path:'/api/registrations', auth:'optional', desc:'Create a registration. Auth optional — guests are allowed when event does not require auth.', request:{ body:{ eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }}, responses:[ { status:201, desc:'Created', body:{ id:'reg-uuid-...', eventId:'ev-uuid-...', status:'pending', checkoutId:null, createdAt:'2025-06-01T10:00:00.000Z' }}, { status:400, desc:'Deadline passed', body:{ message:'Registration deadline has passed.' }}, ]}, { method:'GET', path:'/api/registrations/myregistrations', auth:'user+', desc:'Get all registrations for the authenticated user', responses:[{ status:200, desc:'Success', body:[{ id:'reg-uuid-...', event:{ id:'ev-uuid-...', title:'Camp 2025' }, status:'paid', createdAt:'2025-06-01T10:00:00.000Z' }]}]}, { method:'GET', path:'/api/registrations/:id', auth:'optional', desc:'Get registration detail by ID', pathParams:{ ':id':'Registration UUID' }, responses:[{ status:200, desc:'Success', body:{ id:'reg-uuid-...', status:'paid', event:{ title:'Camp 2025' }, registrationOptions:[{ eventOption:{ name:'Adult' }, quantity:1 }], payments:[{ amount:450, method:'card', status:'succeeded' }] }}]}, { method:'PUT', path:'/api/registrations/:id', auth:'staff+', desc:'Update registration status. If set to "paid", generates tickets and emails them fire-and-forget. If downgraded from "paid", deletes unused tickets (blocks if any ticket has been scanned).', pathParams:{ ':id':'Registration UUID' }, request:{ body:{ status:'paid' }}, responses:[{ status:200, desc:'Updated', body:{ id:'reg-uuid-...', status:'paid' }}]}, { method:'DELETE', path:'/api/registrations/:id', auth:'user+', desc:'Cancel registration. Users: only allowed when no positive payments exist (returns 403 otherwise). Admins: always allowed.', pathParams:{ ':id':'Registration UUID' }, responses:[ { status:200, desc:'Cancelled', body:{ message:'Registration cancelled', registration:{ id:'reg-uuid-...', status:'cancelled' }}}, { status:400, desc:'Already cancelled', body:{ message:'Registration is already cancelled' }}, { status:403, desc:'Payments exist — non-admin cannot cancel', body:{ message:'This registration has payments recorded against it and cannot be self-cancelled. Please contact the organisation for assistance.' }}, { status:403, desc:'Not the owner (and not admin)', body:{ message:'Not authorized to cancel this registration' }}, ]}, { method:'GET', path:'/api/registrations', auth:'staff+', desc:'List all registrations across all events', queryParams:{ page:'Page (default 1)', limit:'Per page (default 20)', eventId:'Filter by event', status:'Filter by status', search:'Search by name/email' }, responses:[{ status:200, desc:'Success', body:{ data:[{ id:'reg-uuid-...', user:{ name:'Jane Doe' }, event:{ title:'Camp 2025' }, status:'paid' }], total:1, page:1 }}]}, { method:'GET', path:'/api/registrations/event/:eventId', auth:'staff+', desc:'Get all registrations for a specific event', pathParams:{ ':eventId':'Event UUID' }, responses:[{ status:200, desc:'Success', body:[{ id:'reg-uuid-...', user:{ id:'user-uuid-...', name:'Jane Doe', email:'jane@example.com', phoneNumber:'+27821234567' }, status:'paid' }]}]}, { method:'POST', path:'/api/registrations/manual', auth:'supervisor+', desc:'Manually create a registration (at-the-door / walk-in). Resolves early-bird tier pricing (including per-variant tiers) and stores priceSnapshot + appliedTierId on each option. Creates or finds a matching user by email/phone. If a matching user is found, updates their notificationPreference and backfills a missing phone number or guest placeholder email with the newly supplied value (without overwriting existing contact info). Generates a Yoco checkout link for unpaid registrations to include in the self-service notification email.', request:{ body:{ eventId:'ev-uuid-...', user:{ name:'Jane Doe', email:'jane@example.com', phoneNumber:'0821234567' }, options:[{ eventOptionId:'opt-uuid-...', quantity:1, variantId:'var-uuid-...' }], notificationPreference:'email' }}, responses:[{ status:201, desc:'Created', body:{ id:'reg-uuid-new', status:'pending' }}]}, { method:'POST', path:'/api/registrations/:id/forms/responses', auth:'optional', desc:'Submit form responses for a registration', pathParams:{ ':id':'Registration UUID' }, request:{ body:{ answers:[{ fieldId:'field-uuid-...', value:'Yes' }, { fieldId:'field-uuid-2', value:'Vegetarian' }] }}, responses:[{ status:201, desc:'Submitted', body:{ id:'response-uuid-...', createdAt:'2025-06-01T10:05:00.000Z' }}]}, ]}, { title: 'Payments', base: '/api/payments', endpoints: [ { method:'POST', path:'/api/payments/yoco-checkout', auth:'user+', desc:'Initiate a Yoco checkout session. Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.', request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.hopehenley.co.za/payment/success', cancelUrl:'https://events.hopehenley.co.za/payment/cancel', failureUrl:'https://events.hopehenley.co.za/payment/failure' }}, responses:[ { status:200, desc:'Checkout created — proceed to Yoco', body:{ redirectUrl:'https://pay.yoco.com/checkout/abc123', checkoutId:'yoco-checkout-id', amount:450 }}, { status:200, desc:'Early-bird price changed — checkout NOT created. Frontend must show warning and let user confirm before retrying.', body:{ priceUpdated:true, newTotal:500, message:'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.' }}, ]}, { method:'POST', path:'/api/payments', auth:'supervisor+', desc:'Record a manual payment (cash, EFT, etc.)', request:{ body:{ registrationId:'reg-uuid-...', amount:450, method:'cash', userId:'user-uuid-...' }}, responses:[ { status:201, desc:'Recorded', body:{ id:'pay-uuid-...', amount:450, method:'cash', status:'succeeded', createdAt:'2025-06-10T09:00:00.000Z' }}, ]}, { method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history (paginated, excludes donations). Returned method is normalized to cash|card|eft|voucher|other — apple_pay/google_pay report as "card", any other gateway-reported value reports as "other"', queryParams:{ page:'Page (default 1)', limit:'Per page (default 25, max 25)', startDate:'ISO date, filters createdAt >=', endDate:'ISO date, filters createdAt <=', method:'Filter by normalized method: cash|card|eft|voucher|other', kind:'payment|refund — filters by amount sign' }, responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }], total:1, page:1, limit:25, pages:1 }}]}, { method:'GET', path:'/api/payments', auth:'supervisor+', desc:'List all payments', queryParams:{ page:'Page (default 1)', limit:'Per page (default 20)', eventId:'Filter by event', userId:'Filter by user', method:'Filter by method (cash|card|eft|donation)', startDate:'ISO date', endDate:'ISO date' }, responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', user:{ name:'Jane Doe' }, registration:{ event:{ title:'Camp 2025' }}}], total:1 }}]}, { method:'GET', path:'/api/payments/event/:eventId', auth:'staff+', desc:'All payments for an event', pathParams:{ ':eventId':'Event UUID' }, responses:[{ status:200, desc:'Success', body:[{ id:'pay-uuid-...', amount:450, user:{ name:'Jane Doe' }, method:'card' }]}]}, { method:'POST', path:'/api/payments/refund', auth:'supervisor+', desc:'Create a refund payment (negative amount)', request:{ body:{ originalPaymentId:'pay-uuid-...', amount:450, reason:'Cancelled registration' }}, responses:[{ status:201, desc:'Refund recorded', body:{ id:'pay-uuid-refund', amount:-450, method:'refund' }}]}, { method:'PUT', path:'/api/payments/assign-donation', auth:'supervisor+', desc:'Link an unassigned donation payment to a specific registration', request:{ body:{ paymentId:'pay-uuid-...', registrationId:'reg-uuid-...' }}, responses:[{ status:200, desc:'Assigned', body:{ id:'pay-uuid-...', registrationId:'reg-uuid-...', isDonation:false }}]}, { method:'GET', path:'/api/payments/admin/stats', auth:'admin', desc:'Aggregated payment statistics', responses:[{ status:200, desc:'Success', body:{ totalRevenue:98500, totalPayments:215, byMethod:{ card:180, cash:30, eft:5 }, byEvent:[{ eventId:'ev-uuid-...', title:'Camp 2025', total:45000 }] }}]}, ]}, { title: 'Tickets', base: '/api/tickets', endpoints: [ { method:'GET', path:'/api/tickets/mytickets', auth:'user+', desc:'Get own tickets', responses:[{ status:200, desc:'Success', body:[{ id:'tkt-uuid-...', qrCode:'TKT-ABC123', event:{ title:'Camp 2025' }, registrationOption:{ eventOption:{ name:'Adult' }}, quantity:1, isUsed:false }]}]}, { method:'POST', path:'/api/tickets/generate', auth:'supervisor+', desc:'Generate tickets for a registration (idempotent — skips already-generated options)', request:{ body:{ registrationId:'reg-uuid-...' }}, responses:[{ status:201, desc:'Generated', body:{ created:2, skipped:0, tickets:[{ id:'tkt-uuid-...', qrCode:'TKT-ABC123' }] }}]}, { method:'POST', path:'/api/tickets/scan/:qrCode', auth:'staff+', desc:'Scan and redeem a ticket', pathParams:{ ':qrCode':'QR code string from ticket' }, request:{ body:{ quantity:1 }}, responses:[ { status:200, desc:'Scanned', body:{ ticket:{ id:'tkt-uuid-...', qrCode:'TKT-ABC123', isUsed:true }, event:{ title:'Camp 2025' }, user:{ name:'Jane Doe' }, quantityRedeemed:1, remainingUses:0 }}, { status:400, desc:'Already used', body:{ message:'Ticket has already been fully redeemed.' }}, { status:404, desc:'Not found', body:{ message:'Ticket not found.' }}, ]}, { method:'GET', path:'/api/tickets/scan-preview/:qrCode', auth:'staff+', desc:'Preview a ticket scan without marking it used', pathParams:{ ':qrCode':'QR code string' }, responses:[{ status:200, desc:'Preview', body:{ ticket:{ qrCode:'TKT-ABC123', quantity:2, isUsed:false }, event:{ title:'Camp 2025' }, user:{ name:'Jane Doe', phoneNumber:'+27821234567' }, totalRedeemed:0 }}]}, { method:'POST', path:'/api/tickets/email', auth:'user+', desc:'Email own tickets as PDF attachments', responses:[{ status:200, desc:'Sent', body:{ message:'Tickets emailed.', count:2 }}]}, { method:'POST', path:'/api/tickets/send-to', auth:'staff+', desc:'Send tickets to a specific user by email or WhatsApp', request:{ body:{ userId:'user-uuid-...', eventId:'ev-uuid-...', channel:'email' }}, responses:[{ status:200, desc:'Sent', body:{ sent:2 }}]}, { method:'GET', path:'/api/tickets/event/:eventId', auth:'staff+', desc:'All tickets for an event', pathParams:{ ':eventId':'Event UUID' }, responses:[{ status:200, desc:'Success', body:[{ id:'tkt-uuid-...', qrCode:'TKT-ABC123', user:{ name:'Jane Doe' }, isUsed:false, quantity:1 }]}]}, { method:'GET', path:'/api/tickets/scans/recent', auth:'staff+', desc:'Last 50 scan events across all events', responses:[{ status:200, desc:'Success', body:[{ ticketId:'tkt-uuid-...', scannedAt:'2025-07-10T09:05:00.000Z', quantityRedeemed:1, ticket:{ user:{ name:'Jane Doe' }, event:{ title:'Camp 2025' }}, scannedBy:{ name:'Staff Member' }}]}]}, { method:'GET', path:'/api/tickets/scans/stats', auth:'staff+', desc:'Scan statistics grouped by event', responses:[{ status:200, desc:'Success', body:[{ eventId:'ev-uuid-...', title:'Camp 2025', totalTickets:50, scanned:38, remaining:12 }]}]}, ]}, { title: 'Broadcasts (Email)', base: '/api/broadcasts', endpoints: [ { method:'POST', path:'/api/broadcasts/preview', auth:'supervisor+', desc:'Dry-run a broadcast — returns resolved recipient list without sending', request:{ body:{ userIds:['user-uuid-1','user-uuid-2'], emails:'extra@example.com\nJohn ', eventId:'ev-uuid-...' }}, responses:[{ status:200, desc:'Preview', body:{ matched:3, recipients:[{ email:'jane@example.com', name:'Jane Doe' },{ email:'extra@example.com', name:null }] }}]}, { method:'POST', path:'/api/broadcasts/send', auth:'supervisor+', desc:'Send an email broadcast to users and/or ad-hoc addresses', request:{ body:{ subject:'Important update', text:'Hi {{name}}, here is a message for you.', userIds:['user-uuid-1'], emails:'extra@example.com', eventId:'ev-uuid-...' }}, responses:[{ status:200, desc:'Sent', body:{ matched:2, sent:2 }}]}, { method:'POST', path:'/api/broadcasts/schedule', auth:'supervisor+', desc:'Schedule an email broadcast for a future time', request:{ body:{ subject:'Save the date!', text:'Hi {{name}}, mark your calendar for {{event.title}}.', userIds:['user-uuid-1'], scheduledAt:'2025-06-01T09:00:00.000Z', eventId:'ev-uuid-...' }}, responses:[{ status:201, desc:'Scheduled', body:{ job:{ id:'job-uuid-...', status:'queued', scheduledAt:'2025-06-01T09:00:00.000Z' }}}]}, ]}, { title: 'Broadcasts (WhatsApp)', base: '/api/whatsapp-broadcasts', endpoints: [ { method:'POST', path:'/api/whatsapp-broadcasts/preview', auth:'supervisor+', desc:'Dry-run — resolve phone list without sending', request:{ body:{ userIds:['user-uuid-1'], phones:'0821234567\nJane <0721234567>', eventId:'ev-uuid-...' }}, responses:[{ status:200, desc:'Preview', body:{ matched:2, recipients:[{ phone:'+27821234567', name:'Jane Doe' }] }}]}, { method:'POST', path:'/api/whatsapp-broadcasts/send', auth:'supervisor+', desc:'Send a WhatsApp broadcast', request:{ body:{ message:'Hi {{name}}, please check your tickets for {{event.title}}.', userIds:['user-uuid-1'], phones:'0821234567', eventId:'ev-uuid-...' }}, responses:[{ status:200, desc:'Sent', body:{ matched:2, sent:2 }}]}, { method:'POST', path:'/api/whatsapp-broadcasts/schedule', auth:'supervisor+', desc:'Schedule a WhatsApp broadcast', request:{ body:{ message:'Hi {{name}}! {{event.title}} is tomorrow. See you there 🙌', userIds:['user-uuid-1'], scheduledAt:'2025-07-09T08:00:00.000Z' }}, responses:[{ status:201, desc:'Scheduled', body:{ job:{ id:'job-uuid-...', status:'queued' }}}]}, ]}, { title: 'Scheduled Messages', base: '/api/scheduled-emails', endpoints: [ { method:'GET', path:'/api/scheduled-emails', auth:'supervisor+', desc:'List all scheduled jobs — email and WhatsApp. Jobs sent more than 7 days ago are hidden.', responses:[{ status:200, desc:'Success', body:{ jobs:[{ id:'job-uuid-...', channel:'email', broadcast:false, eventId:'ev-uuid-...', status:'queued', scheduledAt:'2025-07-03T09:00:00.000Z', attempts:0, payload:{ subject:'Camp reminder' }}]}}]}, { method:'PATCH', path:'/api/scheduled-emails/:id', auth:'supervisor+', desc:'Edit a queued job — reschedule or update its message content', pathParams:{ ':id':'Scheduled job UUID' }, request:{ body:{ scheduledAt:'2025-07-04T09:00:00.000Z', subject:'Updated subject', text:'Hi {{name}}, updated message.' }}, responses:[ { status:200, desc:'Updated', body:{ id:'job-uuid-...', scheduledAt:'2025-07-04T09:00:00.000Z', status:'queued' }}, { status:400, desc:'Already sent', body:{ message:'Cannot edit a job that has already been sent.' }}, ]}, { method:'DELETE', path:'/api/scheduled-emails/:id', auth:'supervisor+', desc:'Cancel and remove a queued job', pathParams:{ ':id':'Scheduled job UUID' }, responses:[{ status:200, desc:'Deleted', body:{ message:'Scheduled job removed.' }}]}, ]}, { title: 'Automations', base: '/api/automations', endpoints: [ { method:'POST', path:'/api/automations/schedule', auth:'supervisor+', desc:'Schedule multiple lifecycle email automations for an event in one call (pre-event, final reminder, thank-you, promo).', request:{ body:{ eventId:'ev-uuid-...', jobs:[ { subject:'1 Week to Go: Camp 2025!', text:'Hi {{name}}, camp is one week away!\n\nDetails: {{event.link}}', scheduledAt:'2025-07-03T09:00:00.000Z' }, { subject:"We'll See You Tomorrow!", text:'Hi {{name}}, final reminder for {{event.title}}. Please have your QR code ready.', scheduledAt:'2025-07-09T09:00:00.000Z' }, ]}}, responses:[{ status:201, desc:'Scheduled', body:{ message:'2 automation(s) scheduled.', jobs:[{ id:'job-uuid-1', status:'queued' },{ id:'job-uuid-2', status:'queued' }] }}]}, ]}, { title: 'Reports', base: '/api/reports', endpoints: [ { method:'POST', path:'/api/reports/pdf', auth:'user+', desc:'Generate a PDF report of own registrations and tickets and return it as a binary download', responses:[{ status:200, desc:'PDF file (application/pdf)', body:'' }]}, { method:'POST', path:'/api/reports/email', auth:'user+', desc:'Generate PDF report and email it to the authenticated user', responses:[{ status:200, desc:'Sent', body:{ message:'Report emailed.' }}]}, ]}, { title: 'Costs', base: '/api/events/:eventId/costs', endpoints: [ { method:'GET', path:'/api/events/:eventId/costs', auth:'supervisor+', desc:'List costs (once-off or per-item) for an event', pathParams:{ ':eventId':'Event UUID' }, responses:[{ status:200, desc:'Success', body:[{ id:'cost-uuid-...', label:'Venue hire', costType:'once_off', amount:2500 },{ id:'cost-uuid-2', label:'Catering', costType:'per_item', amount:75, eventOptionId:'opt-uuid-...' }]}]}, { method:'POST', path:'/api/events/:eventId/costs', auth:'admin', desc:'Create a cost for an event. eventOptionId is required when costType is per_item. paidFromMethod (cash/card/eft/other) optionally tags which float it was paid out of. Rejected once the event is closed.', pathParams:{ ':eventId':'Event UUID' }, request:{ body:{ label:'Catering', costType:'per_item', amount:75, eventOptionId:'opt-uuid-...', paidFromMethod:'cash' }}, responses:[{ status:201, desc:'Created', body:{ id:'cost-uuid-new', label:'Catering', costType:'per_item', amount:75, paidFromMethod:'cash' }}]}, { method:'PUT', path:'/api/costs/:id', auth:'admin', desc:'Update a cost. Rejected once the event is closed.', pathParams:{ ':id':'EventCost UUID' }, request:{ body:{ amount:80 }}, responses:[{ status:200, desc:'Updated', body:{ id:'cost-uuid-...', amount:80 }}]}, { method:'DELETE', path:'/api/costs/:id', auth:'admin', desc:'Delete a cost. Rejected once the event is closed.', pathParams:{ ':id':'EventCost UUID' }, responses:[{ status:200, desc:'Deleted', body:{ message:'Cost deleted' }}]}, ]}, { title: 'Cashups', base: '/api/cashups', endpoints: [ { method:'GET', path:'/api/cashups/event/:eventId', auth:'supervisor+', desc:'Live cashup preview for an event: system income vs. reconciled actual per method, method-tagged costs, unallocated donations, net profit, and the full close/reopen history', pathParams:{ ':eventId':'Event UUID' }, responses:[{ status:200, desc:'Success', body:{ event:{ id:'ev-uuid-...', title:'Camp 2025', cashupStatus:'open' }, paymentsByMethod:{ cash:1200, card:4500, eft:600, other:0 }, expectedCashByMethod:{ cash:1150, card:4500, eft:600, other:0 }, unallocatedDonationsTotal:150, totalCosts:900, netProfit:6450, history:[] }}]}, { method:'PUT', path:'/api/cashups/event/:eventId/draft', auth:'admin', desc:'Save in-progress reconciliation entries (actual counted amounts, and cash denomination counts, per method) without closing the event', pathParams:{ ':eventId':'Event UUID' }, request:{ body:{ lines:[{ method:'cash', denominations:[{ value:100, count:5 },{ value:50, count:2 }] },{ method:'card', actualAmount:4500 }] }}, responses:[{ status:200, desc:'Saved', body:{ message:'Draft saved' }}]}, { method:'POST', path:'/api/cashups/event/:eventId/close', auth:'admin', desc:'Close the event. Pass "lines" for a full per-method cashup (variances computed against expected cash, i.e. income minus any method-tagged costs) — for the cash line, pass "denominations" and the actual amount is computed from the note/coin counts server-side. Omit "lines" for a quick close that accepts system numbers as-is. Either way, unallocated donations are counted as profit and the event is fully locked (no new registrations, payments, refunds, checkouts, or donations) until an admin reopens it. Creates a permanent EventCashup audit row.', pathParams:{ ':eventId':'Event UUID' }, request:{ body:{ lines:[{ method:'cash', denominations:[{ value:100, count:5 },{ value:50, count:2 }], notes:'R20 short' }], notes:'Closed after evening service' }}, responses:[{ status:201, desc:'Closed', body:{ id:'cashup-uuid-...', action:'closed', unallocatedDonationsTotal:150, totalCosts:900, totalExpectedRevenue:6300, totalActualRevenue:6280 }}]}, { method:'POST', path:'/api/cashups/event/:eventId/reopen', auth:'admin', desc:'Reopen a closed event, unlocking registrations/payments/donations again. Admin-only. Creates a permanent "reopened" EventCashup audit row.', pathParams:{ ':eventId':'Event UUID' }, request:{ body:{ notes:'Reopening to correct a miscounted cash drawer' }}, responses:[{ status:201, desc:'Reopened', body:{ id:'cashup-uuid-...', action:'reopened' }}]}, { method:'GET', path:'/api/cashups/audit', auth:'supervisor+', desc:'Flat audit log of every close/quick-close/reopen across events, optionally filtered by eventId and/or from/to dates', request:{ query:{ eventId:'ev-uuid-... (optional)', from:'2026-01-01 (optional)', to:'2026-12-31 (optional)' }}, responses:[{ status:200, desc:'Success', body:[{ id:'cashup-uuid-...', action:'closed', event:{ id:'ev-uuid-...', title:'Camp 2025' }, performedBy:{ name:'Jane Supervisor' }, createdAt:'2026-07-20T18:00:00.000Z' }]}]}, ]}, { title: 'Uploads', base: '/api/uploads', endpoints: [ { method:'POST', path:'/api/uploads/event-image', auth:'supervisor+', desc:'Upload an event cover image. Send as multipart/form-data with field name "image". Returns the public URL.', notes:'Content-Type must be multipart/form-data. Max size is typically 5 MB.', responses:[ { status:200, desc:'Uploaded', body:{ url:'/uploads/event-images/camp-2025-abc123.jpg' }}, { status:400, desc:'No file', body:{ message:'No image file provided.' }}, ]}, ]}, { title: 'Sections', base: '/api/sections', endpoints: [ { method:'GET', path:'/api/sections', auth:'staff+', desc:'List all sections with their allowed options', responses:[{ status:200, desc:'Success', body:[{ id:'sec-uuid-...', eventId:'ev-uuid-...', name:'Adults', allowedOptions:[{ eventOptionId:'opt-uuid-...', eventOption:{ name:'Adult', price:450 }}] }]}]}, { method:'POST', path:'/api/sections', auth:'supervisor+', desc:'Create a section for an event', request:{ body:{ eventId:'ev-uuid-...', name:'Adults', optionIds:['opt-uuid-1','opt-uuid-2'] }}, responses:[{ status:201, desc:'Created', body:{ id:'sec-uuid-new', name:'Adults', eventId:'ev-uuid-...' }}]}, { method:'PUT', path:'/api/sections/:id', auth:'supervisor+', desc:'Update a section', pathParams:{ ':id':'Section UUID' }, request:{ body:{ name:'Adults & Teens', optionIds:['opt-uuid-1','opt-uuid-2','opt-uuid-3'] }}, responses:[{ status:200, desc:'Updated', body:{ id:'sec-uuid-...', name:'Adults & Teens' }}]}, { method:'DELETE', path:'/api/sections/:id', auth:'admin', desc:'Delete a section', pathParams:{ ':id':'Section UUID' }, responses:[{ status:200, desc:'Deleted', body:{ message:'Section deleted.' }}]}, ]}, { title: 'Banner', base: '/api/banner', endpoints: [ { method:'GET', path:'/api/banner', auth:'public', desc:'Get the current site-wide announcement banner', responses:[ { status:200, desc:'Banner set', body:{ message:'Registration for Camp 2025 is now open!', type:'info', active:true }}, { status:200, desc:'No banner', body:{ message:null, active:false }}, ]}, { method:'POST', path:'/api/banner', auth:'supervisor+', desc:'Set or update the site banner', request:{ body:{ message:'Registration is now closed.', type:'warning', active:true }}, responses:[{ status:200, desc:'Saved', body:{ message:'Registration is now closed.', type:'warning', active:true }}]}, ]}, { title: 'Forms', base: '/api/forms', endpoints: [ { method:'GET', path:'/api/forms/responses', auth:'staff+', desc:'List all submitted form responses across all events', queryParams:{ eventId:'Filter by event UUID', page:'Page (default 1)', limit:'Per page (default 20)' }, responses:[{ status:200, desc:'Success', body:{ data:[{ id:'resp-uuid-...', registrationId:'reg-uuid-...', createdAt:'2025-06-01T10:05:00.000Z', answers:[{ field:{ label:'Dietary requirements' }, value:'Vegetarian' }] }], total:1 }}]}, ]}, { title: 'Yoco Transactions', base: '/api/yoco-transactions', endpoints: [ { method:'GET', path:'/api/yoco-transactions', auth:'supervisor+', desc:'All Yoco webhook transactions (raw events received from Yoco)', queryParams:{ page:'Page (default 1)', limit:'Per page (default 50)', reconciled:'true|false' }, responses:[{ status:200, desc:'Success', body:{ data:[{ id:'ytx-uuid-...', externalId:'pi_yoco_abc', amount:45000, currency:'ZAR', reconciled:true, paymentId:'pay-uuid-...', createdDate:'2025-06-01T11:00:00.000Z' }], total:1 }}]}, { method:'GET', path:'/api/yoco-transactions/unreconciled', auth:'supervisor+', desc:'Transactions not yet matched to a payment record', responses:[{ status:200, desc:'Success', body:{ data:[{ id:'ytx-uuid-...', externalId:'pi_yoco_xyz', amount:45000, currency:'ZAR', reconciled:false }], total:1 }}]}, { method:'POST', path:'/api/yoco-transactions/:id/reconcile', auth:'supervisor+', desc:'Manually link a Yoco transaction to an existing payment', pathParams:{ ':id':'YocoTransaction UUID' }, request:{ body:{ paymentId:'pay-uuid-...' }}, responses:[{ status:200, desc:'Reconciled', body:{ id:'ytx-uuid-...', reconciled:true, paymentId:'pay-uuid-...' }}]}, { method:'POST', path:'/api/yoco-transactions/:id/ignore', auth:'supervisor+', desc:'Mark a transaction as intentionally ignored', pathParams:{ ':id':'YocoTransaction UUID' }, responses:[{ status:200, desc:'Ignored', body:{ id:'ytx-uuid-...', ignored:true }}]}, ]}, { title: 'WhatsApp Admin', base: '/api/whatsapp', endpoints: [ { method:'GET', path:'/api/whatsapp/config', auth:'admin', desc:'Get the currently stored WAWP credentials from DB (tokens are partially masked)', responses:[{ status:200, desc:'Success', body:{ instanceId:'wawp-instance-abc', accessToken:'eyJ...****', source:'db' }}]}, { method:'POST', path:'/api/whatsapp/config', auth:'admin', desc:'Save WAWP credentials to the database (AppSetting). Takes effect immediately; no restart needed.', request:{ body:{ accessToken:'eyJhbGciOiJSUzI1...', instanceId:'wawp-instance-abc' }}, responses:[{ status:200, desc:'Saved', body:{ message:'WhatsApp config saved.' }}]}, { method:'GET', path:'/api/whatsapp/status', auth:'admin', desc:'Get the current WAWP session status', responses:[ { status:200, desc:'Connected', body:{ status:'open', phoneNumber:'+27821234567', pushName:'Hope Events' }}, { status:200, desc:'Not connected', body:{ status:'close' }}, ]}, { method:'GET', path:'/api/whatsapp/qr', auth:'admin', desc:'Get a QR code image/string to link a WhatsApp account', responses:[{ status:200, desc:'Success', body:{ qr:'data:image/png;base64,...', qrString:'2@abc123...' }}]}, { method:'POST', path:'/api/whatsapp/create-instance', auth:'admin', desc:'Create a new WAWP session instance', responses:[{ status:200, desc:'Created', body:{ instanceId:'wawp-instance-new', message:'Instance created.' }}]}, { method:'POST', path:'/api/whatsapp/restart', auth:'admin', desc:'Restart the current WAWP instance', responses:[{ status:200, desc:'Restarted', body:{ message:'Instance restarted.' }}]}, { method:'POST', path:'/api/whatsapp/logout', auth:'admin', desc:'Log out the current WhatsApp session', responses:[{ status:200, desc:'Logged out', body:{ message:'Logged out.' }}]}, ]}, { title: 'Webhooks', base: '/api/webhooks', endpoints: [ { method:'POST', path:'/api/webhooks/yoco', auth:'HMAC', desc:'Yoco payment event webhook. Called by Yoco servers. Verified via HMAC-SHA256 signature in the X-Yoco-Signature header.', notes:'This endpoint bypasses the global rate limiter. Raw body is parsed before JSON middleware. Refreshes early-bird pricing before computing totalDue to ensure accurate paid/partial status.', request:{ body:{ id:'evt_yoco_abc', type:'payment.succeeded', payload:{ metadata:{ checkoutId:'checkout-id-...' }, amount:45000, currency:'ZAR' }}}, responses:[{ status:200, desc:'Acknowledged', body:{ received:true }}]}, { method:'POST', path:'/api/webhooks/whatsapp', auth:'—', desc:'WAWP inbound message event. Called by WAWP servers when a message arrives on the linked number.', responses:[{ status:200, desc:'Acknowledged', body:{ received:true }}]}, ]}, { title: 'Settings', base: '/api/settings', endpoints: [ { method:'GET', path:'/api/settings', auth:'public', desc:'Public settings — org name, accent colour, logo URL, legal page slugs, registration notification email (no secrets)', responses:[{ status:200, desc:'Success', body:{ org_name:'Hope Family Church', org_tagline:'Where everyone belongs', accent_color:'#2563eb', logo_url:'/uploads/logo.png' }}]}, { method:'GET', path:'/api/settings/all', auth:'admin', desc:'All settings including SMTP config. smtp_pass is returned masked (••••••••); smtp_user is returned decrypted.', responses:[{ status:200, desc:'Success', body:[{ key:'smtp_host', value:'smtp.example.com' },{ key:'smtp_user', value:'user@example.com' },{ key:'smtp_pass', value:'••••••••' }]}]}, { method:'GET', path:'/api/settings/needs-setup', auth:'public', desc:'Returns true until the setup wizard has been completed (setup_complete flag is set).', responses:[{ status:200, desc:'Success', body:{ needsSetup:true }}]}, { method:'PUT', path:'/api/settings', auth:'admin', desc:'Upsert one or more settings. smtp_user and smtp_pass are AES-256-GCM encrypted before storage. Sending •••••••• for smtp_pass is a no-op.', request:{ body:{ org_name:'Hope Family Church', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password' }}, responses:[{ status:200, desc:'Saved', body:{ message:'Settings saved.' }}]}, { method:'POST', path:'/api/settings/test-smtp', auth:'admin (or setup token)', desc:'Test the SMTP connection with provided credentials. On success, sends a real test email to the authenticated admin and returns a friendly message. On failure, returns a human-readable message plus a raw field containing the original SMTP error for debugging. Error code 530 (Microsoft "Client not authenticated") maps to the authentication-failure message.', request:{ body:{ host:'smtp.gmail.com', port:587, secure:false, user:'me@gmail.com', pass:'app-password', from:'me@gmail.com' }}, responses:[ { status:200, desc:'Connection OK — test email sent', body:{ message:'SMTP connection verified — a test email has been sent to admin@example.com' }}, { status:400, desc:'Connection failed', body:{ message:'Authentication failed — check your SMTP username and password.', raw:'535 5.7.8 Error: authentication failed' }}, ]}, ]}, { title: 'Setup', base: '/api/setup', endpoints: [ { method:'POST', path:'/api/setup/register', auth:'public (one-time)', desc:'Step 2 of the first-time setup wizard. Creates the admin account and returns a short-lived JWT. Blocked once any user exists.', request:{ body:{ name:'Admin User', email:'admin@example.com', password:'strong-password' }}, responses:[ { status:201, desc:'Created', body:{ token:'eyJhbGciOiJIUzI1NiJ9...', user:{ id:'uuid-...', name:'Admin User', role:'admin' }}}, { status:400, desc:'Already exists', body:{ message:'Setup already completed — users exist.' }}, ]}, { method:'POST', path:'/api/setup', auth:'setup token or admin', desc:'Final step of the setup wizard. Saves initial site settings. Requires the JWT returned by POST /api/setup/register.', request:{ body:{ settings:{ org_name:'Hope Family Church', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password', mail_from:'noreply@example.com' }}}, responses:[{ status:200, desc:'Setup complete', body:{ message:'Setup complete.' }}]}, ]}, ]; // ── Rendering helpers ───────────────────────────────────────────────────── function hl(json) { // Simple JSON syntax highlighting — server-side const s = JSON.stringify(json, null, 2); return s .replace(/&/g,'&').replace(//g,'>') .replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, (m) => { if (/^"/.test(m)) { if (/:$/.test(m)) return `${m}`; // key return `${m}`; // string value } if (/true|false/.test(m)) return `${m}`; if (/null/.test(m)) return `${m}`; return `${m}`; // number }); } function codeBlock(content, lang) { return `
${content}
`; } function headerLine(key, val) { return `${key}: ${val}`; } function renderEndpoint(ep, idx) { const id = `ep${idx}`; const methodCls = { GET:'get', POST:'post', PUT:'put', PATCH:'patch', DELETE:'del' }[ep.method] || 'get'; const hasDetail = ep.notes || ep.pathParams || ep.queryParams || ep.request || ep.responses; // Build HTTP request example let reqLines = []; const needsAuth = ep.auth !== 'public' && ep.auth !== 'HMAC' && ep.auth !== '—'; const isMultipart = ep.notes && ep.notes.includes('multipart'); reqLines.push(`${ep.method} ${ep.path} HTTP/1.1`); if (needsAuth) reqLines.push(headerLine('Authorization','Bearer <your-jwt>')); if (ep.request?.body && !isMultipart) reqLines.push(headerLine('Content-Type','application/json')); if (isMultipart) reqLines.push(headerLine('Content-Type','multipart/form-data')); if (ep.request?.body && !isMultipart) { reqLines.push(''); reqLines.push(hl(ep.request.body)); } else if (isMultipart) { reqLines.push(''); reqLines.push(`-- form field: image (binary file) --`); } const reqHtml = codeBlock(reqLines.join('\n')); // Build response examples const resHtml = (ep.responses || []).map(r => { const statusColor = r.status < 300 ? '#22c55e' : r.status < 500 ? '#f59e0b' : '#ef4444'; const body = typeof r.body === 'string' ? `${r.body}` : hl(r.body); return `
HTTP ${r.status} ${r.desc}
${codeBlock(body)}
`; }).join(''); // Path params table let paramsHtml = ''; if (ep.pathParams && Object.keys(ep.pathParams).length) { const rows = Object.entries(ep.pathParams).map(([k,v]) => `${k}${v}`).join(''); paramsHtml += `
Path parameters
${rows}
`; } if (ep.queryParams && Object.keys(ep.queryParams).length) { const rows = Object.entries(ep.queryParams).map(([k,v]) => `${k}${v}`).join(''); paramsHtml += `
Query parameters
${rows}
`; } const notesHtml = ep.notes ? `
${ep.notes}
` : ''; const detailHtml = hasDetail ? `
${notesHtml} ${paramsHtml}
Request
${reqHtml}
Responses
${resHtml}
` : ''; const cursor = hasDetail ? 'cursor:pointer' : ''; const chevron = hasDetail ? `` : ''; const onclick = hasDetail ? `onclick="toggle('${id}')"` : ''; return ` ${ep.method} ${ep.path} ${ep.auth} ${ep.desc} ${chevron} ${detailHtml}`; } function renderGroup(group) { const rows = group.endpoints.map((ep, i) => renderEndpoint(ep, `${group.title.replace(/\W+/g,'')}-${i}`)).join(''); return `

${group.title} ${group.base}

${rows}
Method Path Auth Description
`; } const groupsHtml = GROUPS.map(renderGroup).join(''); // ── Notifications reference — every automatic email/WhatsApp message the system sends ── const NOTIFICATIONS = [ { category: 'Registration', items: [ { trigger: 'New registration — self-service (attendee registers via the public website)', channels: ['Email', 'WhatsApp'], recipients: 'Registrant', subject: 'Registration confirmed – {event}', content: 'Selections table, total due / paid / balance, payment options (website or at the door), and an account login / create-account prompt.' }, { trigger: 'New registration — self-service', channels: ['Email'], recipients: 'Admin — registrations inbox + event notify recipients (falls back to the event creator when none are set)', subject: 'New registration: {event} — {name}', content: 'Registrant name, email, phone, status, items table, total due / paid / balance, registration ID.' }, { trigger: 'New registration — staff/at-door (manual registration, self-service kiosk)', channels: ['Email', 'WhatsApp'], recipients: 'Registrant', subject: 'Registration confirmed – {event}', content: 'Same as above, plus a Yoco pay-now link when a balance is owing.' }, { trigger: 'New registration — staff/at-door', channels: ['Email'], recipients: 'Admin — registrations inbox + event notify recipients (falls back to the event creator)', subject: 'New registration: {event} — {name}', content: 'Same layout as the self-service admin notice.' }, { trigger: 'Registration updated (options changed by staff, or a free registration completes)', channels: ['Email', 'WhatsApp'], recipients: 'Registrant', subject: 'Registration updated – {event}', content: 'Updated selections/total, same payment & account sections as the confirmation email.' }, { trigger: 'Registration updated', channels: ['Email'], recipients: 'Admin — registrations inbox + event notify recipients (falls back to the event creator)', subject: 'Registration updated: {event} — {name}', content: 'Same layout as the new-registration admin notice.' }, ]}, { category: 'Payments', items: [ { trigger: 'Payment recorded (Yoco webhook success, manual cash/EFT entry, or a donation assigned to a registration)', channels: ['Email', 'WhatsApp'], recipients: 'Payer', subject: 'Payment received – {event} (or "Donation received – {event}" for unassigned donations)', content: 'Amount, method, date, full payment history table, updated balance.' }, { trigger: 'Payment recorded', channels: ['Email'], recipients: 'Admin — registrations inbox + event notify recipients (falls back to the event creator)', subject: 'Payment recorded: {amount} — {payer} ({event})', content: 'Amount, type (registration payment / donation), payer, event, method, date, payment ID, external (Yoco) ID.' }, { trigger: 'Refund processed (negative payment created against an original payment)', channels: ['Email'], recipients: 'Payer', subject: 'Refund processed – {amount} for {event}', content: 'Refund amount, method, date, reason (when supplied).' }, { trigger: 'Refund processed', channels: ['Email'], recipients: 'Admin — registrations inbox + event notify recipients (falls back to the event creator)', subject: 'Refund: Payment recorded: {amount} — {payer} ({event})', content: 'Same payment admin notice content, subject prefixed "Refund:".' }, ]}, { category: 'Tickets', items: [ { trigger: 'Tickets generated — fires whenever a registration becomes fully paid or is free (webhook, manual payment, staff status change, or free/no-cost registration)', channels: ['Email (PDF attached)', 'WhatsApp (PDF)'], recipients: 'Registrant (or a specific user when sent manually via "Send to")', subject: 'Your tickets for {event}', content: 'QR-coded ticket PDF, one per registered option/quantity; WhatsApp caption names the event and date.' }, ]}, { category: 'Daily digest', items: [ { trigger: 'Scheduled — every day at 07:00 local server time, once per active event that has gone live and has not yet started', channels: ['Email'], recipients: 'Admin — registrations inbox + event notify recipients (falls back to the event creator)', subject: 'Daily summary: {event} — {date}', content: 'Stat tiles (registrations, paid, awaiting payment, revenue), full registrations table with balances, full payments & donations table.' }, ]}, { category: 'Account & security', items: [ { trigger: 'New account registered', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'New user', subject: 'Welcome to Hope Events!', content: 'Welcome message plus a list of upcoming events.' }, { trigger: 'Login attempt on an account that is not yet active', channels: ['Email (if a real address is on file)', 'WhatsApp (fallback when there is no usable email)'], recipients: 'User', subject: 'Activate your Hope Events account', content: 'One-time activation link; expires after 24 hours.' }, { trigger: 'Successful login', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'New login to your Hope Events account', content: 'Login time, approximate location, device/user agent. Security alert — always emailed regardless of the user\'s notification preference.' }, { trigger: 'Password changed via profile update', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'Your Hope Events password was changed', content: 'Confirms the change and gives a support contact to use if it wasn\'t them.' }, { trigger: 'Forgot-password request', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'Reset your password', content: 'Password reset link.' }, { trigger: 'Account closed (self-service — "Deactivate" or "Delete my data")', channels: ['Email', 'WhatsApp (if preferred)'], recipients: 'User (sent to their last-known address/number just before data is wiped)', subject: 'Your Hope Events account has been closed', content: 'Confirms closure; wording differs slightly when personal data was also erased.' }, ]}, { category: 'Admin-triggered broadcasts', items: [ { trigger: 'Bulk email to event attendees, or an ad-hoc email broadcast — sent immediately or on a schedule', channels: ['Email'], recipients: 'Selected attendees / users / ad-hoc addresses chosen by the sender', subject: 'Admin-authored', content: 'Free-form subject & body written by the sender, supporting {{name}}, {{event.title}}, {{event.link}} placeholders. Used for reminders, thank-yous, promos, and multi-step automations.' }, { trigger: 'Bulk WhatsApp to event attendees, or an ad-hoc WhatsApp broadcast — sent immediately or on a schedule', channels: ['WhatsApp'], recipients: 'Selected attendees / users / ad-hoc phone numbers chosen by the sender', subject: '—', content: 'Free-form message written by the sender, same placeholder support as email broadcasts.' }, ]}, ]; function notifChannelBadges(channels) { return channels.map(c => { const isWa = /whatsapp/i.test(c); return `${c}`; }).join(''); } function renderNotificationRow(n) { return ` ${n.trigger} ${notifChannelBadges(n.channels)} ${n.recipients} ${n.subject} ${n.content} `; } function renderNotificationCategory(cat) { const rows = cat.items.map(renderNotificationRow).join(''); return `

${cat.category}

${rows}
Trigger — when it's sent Channel Recipients Subject Content
`; } const notificationsHtml = NOTIFICATIONS.map(renderNotificationCategory).join(''); const html = ` Hope Events — API Docs

Hope Events — API Reference

← Status page

Logged in as ${user.name || user.email} (${user.role}) — Click any row to expand request & response examples.

Protected routes require Authorization: Bearer <jwt>  |  Roles: admin > supervisor > staff > user  |  Base URL: ${process.env.APP_BASE_URL || 'http://localhost:' + PORT}
${groupsHtml}

Notifications sent by the system

Every automatic email and WhatsApp message the platform sends, what triggers it, who receives it, and what it contains. Admin-facing registration/payment/daily-summary notices go to the reg_notification_emails setting plus each event's configured notify recipients (Admin → Events → edit event → Notifications step) — falling back to that event's creator when no recipients have been chosen.

${notificationsHtml}

Hope Events API v${API_VERSION} — ${new Date().toISOString()}

`; res.send(html); }); // Error middleware app.use(notFound); 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); } // 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); } }); // Handle unhandled promise rejections process.on('unhandledRejection', (err) => { console.log('UNHANDLED REJECTION! Shutting down...'); console.log(err.name, err.message); process.exit(1); }); module.exports = { app, prisma };