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}
Uptime
${formatUptime(uptimeSec)}
Heap Used
${formatBytes(mem.heapUsed)}
Heap Total
${formatBytes(mem.heapTotal)}
RSS
${formatBytes(mem.rss)}
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 += ``;
}
if (ep.queryParams && Object.keys(ep.queryParams).length) {
const rows = Object.entries(ep.queryParams).map(([k,v]) =>
`${k} ${v} `).join('');
paramsHtml += ``;
}
const notesHtml = ep.notes
? `${ep.notes}
`
: '';
const detailHtml = hasDetail ? `
${notesHtml}
${paramsHtml}
` : '';
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}
Method
Path
Auth
Description
${rows}
`;
}
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}
Trigger — when it's sent
Channel
Recipients
Subject
Content
${rows}
`;
}
const notificationsHtml = NOTIFICATIONS.map(renderNotificationCategory).join('');
const html = `
Hope Events — API Docs
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 };