Files
hope-events/backend/src/index.js
T
joshuaandClaude Opus 5.5 763b66ff56 Add bundle deals
Sell a set of tickets together for one price (e.g. 5 Adult + 3 Child for R500).
Bundles apply automatically, as many times as a registration's tickets allow, and
only when cheaper than the prices actually charged. Coupons come off afterwards.

- EventBundle/EventBundleItem/RegistrationBundle models and Registration.bundleDiscount
- Supervisor "Bundle deals" page, quote endpoint, bundle lines on invoices,
  receipts, emails, WhatsApp and Yoco checkout, Discounts report rows
- Register form shows started bundles with "Complete bundle" and "+" buttons
- Demo: Family Fun Day sells a Family deal bundle instead of the Family pass ticket

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:14:53 +02:00

1695 lines
127 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const path = require('path');
const dotenv = require('dotenv');
// Load environment variables
dotenv.config();
// Error monitoring — a no-op if SENTRY_DSN isn't set, so this is safe in every
// environment (dev, a fresh deploy that hasn't configured Sentry yet, etc.).
// Must run before express/@prisma/client are required below — Sentry's
// auto-instrumentation patches those modules via a require hook, which only
// works if Sentry.init() runs before they're first required into the cache.
if (process.env.SENTRY_DSN) {
const Sentry = require('@sentry/node');
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV || 'development',
// Small single-VM deployment, not high-traffic — start conservative and raise
// this once real usage is visible in Sentry, rather than sampling every request.
tracesSampleRate: 0.1,
});
}
const express = require('express');
const { version: API_VERSION } = require('../package.json');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const prisma = require('./config/db');
const { notFound, errorHandler } = require('./middleware/errorMiddleware');
const getRawBody = require('raw-body');
// Initialize Express app
const app = express();
const PORT = process.env.PORT || 3000;
// Trust proxy — required when running behind a reverse proxy (e.g. nginx on a
// separate server) so req.ip / X-Forwarded-For are read correctly by
// express-rate-limit and friends. Accepts a hop count ("1"), "true"/"false",
// or a comma-separated list of trusted proxy IPs/CIDRs.
if (process.env.TRUST_PROXY) {
const raw = process.env.TRUST_PROXY.trim();
let trustProxyValue;
if (raw === 'true') trustProxyValue = true;
else if (raw === 'false') trustProxyValue = false;
else if (/^\d+$/.test(raw)) trustProxyValue = parseInt(raw, 10);
else if (raw.includes(',')) trustProxyValue = raw.split(',').map((s) => s.trim());
else trustProxyValue = raw;
app.set('trust proxy', trustProxyValue);
console.log(`[startup] trust proxy set to: ${JSON.stringify(trustProxyValue)}`);
}
// CORS — allow only the configured frontend origin
const allowedOrigins = (process.env.FRONTEND_URL || 'http://localhost:3000')
.split(',')
.map((o) => o.trim());
const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV;
const isTesting = process.env.NODE_ENV === 'testing';
const isProd = process.env.NODE_ENV === 'production';
const modeLabel = isProd ? 'production' : isTesting ? 'testing' : 'development';
console.log(`Running in ${modeLabel} mode.`);
// In testing or production, require all critical env vars to be set at startup
if (isTesting || isProd) {
const required = ['DATABASE_URL', 'JWT_SECRET'];
const missing = required.filter(k => !process.env[k]);
if (missing.length > 0) {
console.error(`[startup] Missing required environment variables for ${modeLabel} mode: ${missing.join(', ')}`);
process.exit(1);
}
}
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (e.g. mobile apps, curl, Postman)
if (!origin) return callback(null, true);
// Always allow configured origins
if (allowedOrigins.includes(origin)) return callback(null, true);
// In dev or testing, also allow any LAN origin on the same port (e.g. phone on 192.168.x.x:3000)
if (isDev || isTesting) {
try {
const u = new URL(origin);
const isLan = /^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(u.hostname);
if (isLan) return callback(null, true);
} catch {}
}
callback(new Error(`CORS: origin '${origin}' not allowed`));
},
credentials: true,
}));
// Global rate limiter — 5000 requests per 5 minutes per IP
// Webhooks are excluded because they come from Yoco's servers and are already HMAC-verified
const globalLimiter = rateLimit({
windowMs: 5 * 60 * 1000,
max: 5000,
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many requests, please try again later.' },
skip: (req) => req.path.startsWith('/api/webhooks'),
});
app.use(globalLimiter);
// Impersonation is strictly read-only: refuse every write made with an impersonation
// token before it can reach any route (see utils/impersonation.js).
app.use(require('./utils/impersonation').readOnlyGuard);
// Raw body parsing middleware for webhooks
app.use((req, res, next) => {
if (req.path.startsWith('/api/webhooks')) {
getRawBody(req, {
length: req.headers['content-length'],
encoding: 'utf-8'
}, (err, rawBody) => {
if (err) return next(err);
req.rawBody = rawBody;
next();
});
} else {
next();
}
});
// Import routes
const userRoutes = require('./routes/userRoutes');
const eventRoutes = require('./routes/eventRoutes');
const registrationRoutes = require('./routes/registrationRoutes');
const paymentRoutes = require('./routes/paymentRoutes');
const ticketRoutes = require('./routes/ticketRoutes');
const webhookRoutes = require('./routes/webhookRoutes');
const uploadRoutes = require('./routes/uploadRoutes');
const reportRoutes = require('./routes/reportRoutes');
const formRoutes = require('./routes/formRoutes');
const yocoTransactionRoutes = require('./routes/yocoTransactionRoutes');
const automationRoutes = require('./routes/automationRoutes');
const broadcastRoutes = require('./routes/broadcastRoutes');
const whatsappBroadcastRoutes = require('./routes/whatsappBroadcastRoutes');
const scheduledEmailRoutes = require('./routes/scheduledEmailRoutes');
const sectionRoutes = require('./routes/sectionRoutes');
const bannerRoutes = require('./routes/bannerRoutes');
const whatsappRoutes = require('./routes/whatsappRoutes');
const settingsRoutes = require('./routes/settingsRoutes');
const setupRoutes = require('./routes/setupRoutes');
const costRoutes = require('./routes/costRoutes');
const cashupRoutes = require('./routes/cashupRoutes');
const statsRoutes = require('./routes/statsRoutes');
const adminRoutes = require('./routes/adminRoutes');
const backupRoutes = require('./routes/backupRoutes');
const yocoRoutes = require('./routes/yocoRoutes');
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
app.use('/api/webhooks', webhookRoutes);
// Apply JSON body parser for all non-webhook routes
app.use(express.json());
// Use routes
app.use('/api/users', userRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/registrations', registrationRoutes);
app.use('/api/payments', paymentRoutes);
app.use('/api/tickets', ticketRoutes);
app.use('/api/uploads', uploadRoutes);
app.use('/api/reports', reportRoutes);
app.use('/api/forms', formRoutes);
app.use('/api/yoco-transactions', yocoTransactionRoutes);
app.use('/api/automations', automationRoutes);
app.use('/api/broadcasts', broadcastRoutes);
app.use('/api/whatsapp-broadcasts', whatsappBroadcastRoutes);
app.use('/api/scheduled-emails', scheduledEmailRoutes);
app.use('/api/sections', sectionRoutes);
app.use('/api/banner', bannerRoutes);
app.use('/api/whatsapp', whatsappRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/setup', setupRoutes);
app.use('/api/stats', statsRoutes);
app.use('/api', costRoutes);
app.use('/api/cashups', cashupRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/coupons', require('./routes/couponRoutes'));
app.use('/api/bundles', require('./routes/bundleRoutes'));
app.use('/api/consent', require('./routes/consentRoutes'));
app.use('/api/waitlist', require('./routes/waitlistRoutes'));
app.use('/api/scan-locks', require('./routes/scanLockRoutes'));
app.use('/api/allocations', require('./routes/allocationRoutes'));
app.use('/api/sponsorships', require('./routes/sponsorshipRoutes'));
app.use('/api/feedback', require('./routes/feedbackRoutes'));
app.use('/api/analytics', require('./routes/analyticsRoutes'));
app.use('/api/backups', backupRoutes);
app.use('/api/yoco', yocoRoutes);
// Pre-warm the settings cache so synchronous helpers have DB values from startup
const { getSettingSync, warmCache } = require('./utils/settingsCache');
warmCache().catch(() => {});
// Uploaded branding assets can include SVGs, which may embed <script>/event
// handlers. Serving them inline lets a compromised/malicious upload run script
// in the site's origin if opened directly, so pin the safe response headers
// (no inline execution, no MIME-sniffing to HTML/script) on every asset here.
app.use('/uploads', express.static('public/uploads', {
setHeaders: (res) => {
res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox");
res.setHeader('X-Content-Type-Options', 'nosniff');
},
}));
// ── Shared page helpers ────────────────────────────────────────────────────────
const jwt = require('jsonwebtoken');
function pageShell(title, accentColor, bodyHtml) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f3f4f6;color:#1f2937;min-height:100vh;padding:24px 16px}
.wrap{max-width:960px;margin:0 auto}
h1{font-size:1.5rem;font-weight:700;color:#111827;margin-bottom:4px}
h2{font-size:1.1rem;font-weight:600;color:#111827;margin:24px 0 10px}
.subtitle{font-size:.85rem;color:#6b7280;margin-bottom:24px}
.card{background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:20px;margin-bottom:16px}
.stat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:16px}
.stat{background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:14px 16px}
.stat-label{font-size:.72rem;text-transform:uppercase;letter-spacing:.05em;color:#6b7280;margin-bottom:4px}
.stat-value{font-size:1.1rem;font-weight:600;color:#111827}
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:.75rem;font-weight:600}
.badge-ok{background:#d1fae5;color:#065f46}
.badge-warn{background:#fef3c7;color:#92400e}
.badge-err{background:#fee2e2;color:#991b1b}
table{width:100%;border-collapse:collapse;font-size:.82rem}
th{text-align:left;padding:6px 10px;background:#f9fafb;border-bottom:1px solid #e5e7eb;font-weight:600;color:#374151}
td{padding:6px 10px;border-bottom:1px solid #f3f4f6;color:#374151;vertical-align:top}
tr:last-child td{border-bottom:none}
.method{display:inline-block;padding:1px 7px;border-radius:4px;font-size:.7rem;font-weight:700;font-family:monospace}
.get{background:#dbeafe;color:#1e40af}
.post{background:#d1fae5;color:#065f46}
.put{background:#fef3c7;color:#92400e}
.patch{background:#ede9fe;color:#5b21b6}
.del{background:#fee2e2;color:#991b1b}
.auth-badge{font-size:.68rem;padding:1px 6px;border-radius:4px;background:#f3f4f6;color:#374151;font-family:monospace}
code{background:#f3f4f6;padding:1px 5px;border-radius:4px;font-family:monospace;font-size:.82rem}
.note{background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;padding:12px 16px;font-size:.82rem;color:#1e40af;margin-bottom:16px}
.accent{color:${accentColor}}
a{color:${accentColor};text-decoration:none}
a:hover{text-decoration:underline}
</style>
</head>
<body>
<div class="wrap">
${bodyHtml}
</div>
</body>
</html>`;
}
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'
? `<span class="badge badge-ok">&#10003; ${dbMsg}</span>`
: `<span class="badge badge-err">&#10007; ${dbMsg}</span>`;
const envBadge = isProd
? `<span class="badge badge-ok">production</span>`
: isTesting
? `<span class="badge badge-warn">testing</span>`
: `<span class="badge badge-warn">development</span>`;
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
const html = pageShell(`${orgName} Events API — Status`, '#2563eb', `
<h1>${orgName} Events API</h1>
<p class="subtitle">v${API_VERSION} &mdash; ${now}</p>
<div class="stat-grid">
<div class="stat">
<div class="stat-label">Status</div>
<div class="stat-value"><span class="badge badge-ok">&#10003; Online</span></div>
</div>
<div class="stat">
<div class="stat-label">Environment</div>
<div class="stat-value">${envBadge}</div>
</div>
<div class="stat">
<div class="stat-label">Database</div>
<div class="stat-value">${dbBadge}</div>
</div>
<div class="stat">
<div class="stat-label">Uptime</div>
<div class="stat-value">${formatUptime(uptimeSec)}</div>
</div>
<div class="stat">
<div class="stat-label">Heap Used</div>
<div class="stat-value">${formatBytes(mem.heapUsed)}</div>
</div>
<div class="stat">
<div class="stat-label">Heap Total</div>
<div class="stat-value">${formatBytes(mem.heapTotal)}</div>
</div>
<div class="stat">
<div class="stat-label">RSS</div>
<div class="stat-value">${formatBytes(mem.rss)}</div>
</div>
<div class="stat">
<div class="stat-label">Node</div>
<div class="stat-value">${process.version}</div>
</div>
</div>
<div class="note">
API documentation is available at <a href="/docs">/docs</a> — requires an admin account token.
</div>
`);
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', `
<h1>API Documentation</h1>
<p class="subtitle">Admin access required</p>
<div class="card">
<p style="margin-bottom:12px;font-size:.9rem;">Pass your admin JWT to view the docs:</p>
<code style="display:block;padding:10px;background:#f3f4f6;border-radius:6px;font-size:.8rem;word-break:break-all;">/docs?token=&lt;your-admin-jwt&gt;</code>
<p style="margin-top:12px;font-size:.8rem;color:#6b7280;">Copy your token from the browser's localStorage key <code>token</code> after logging in as admin.</p>
</div>`));
}
let user;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
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', `
<h1>Access Denied</h1>
<p class="subtitle">Admin role required</p>
<div class="card">
<p style="font-size:.9rem;color:#dc2626;">${e.message}</p>
<p style="margin-top:10px;font-size:.82rem;color:#6b7280;">Make sure you are logged in as an admin and using a valid, non-expired token.</p>
</div>`));
}
// ── Endpoint data ────────────────────────────────────────────────────────
const GROUPS = [
{ title: 'Users', base: '/api/users', endpoints: [
{ method:'GET', path:'/api/users/activity', auth:'Bearer', desc:'Recent account activity (logins, password changes) for the current user' },
{ 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:200, desc:'Two-factor code sent (account has 2FA on) — no token yet', body:{ twoFactorRequired:true, challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
{ status:200, desc:'Admin/supervisor without 2FA — code sent; verifying it turns 2FA on', body:{ twoFactorRequired:true, enrolling:true, challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
{ status:401, desc:'Wrong credentials', body:{ message:'Invalid email or password' }},
]},
{ method:'POST', path:'/api/users/login/2fa', auth:'public', desc:'Second login step — exchange the emailed/WhatsApp code for a JWT',
notes:'Codes expire after 10 minutes and allow 5 attempts. With rememberDevice, the response includes a deviceToken; send it with later logins to skip the code for 30 days.',
request:{ body:{ challengeId:'c0ffee00-...', code:'123456', rememberDevice:true }},
responses:[
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...', deviceToken:'9f2c...' }},
{ status:400, desc:'Wrong or expired code', body:{ message:'Incorrect code. 4 attempts left.' }},
]},
{ method:'POST', path:'/api/users/login/2fa/resend', auth:'public', desc:'Send a new code for a pending login challenge',
notes:'At most one send per 60 seconds.',
request:{ body:{ challengeId:'c0ffee00-...' }},
responses:[
{ status:200, desc:'Sent', body:{ challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
{ status:429, desc:'Too soon', body:{ message:'Please wait 42 seconds before requesting another code.' }},
]},
{ 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:'POST', path:'/api/users/2fa/enable', auth:'user+', desc:'Start turning on two-factor authentication — sends a code to the chosen channel',
notes:'WhatsApp needs a valid phone number on the account and a connected WhatsApp session.',
request:{ body:{ method:'email', password:'secret123' }},
responses:[
{ status:200, desc:'Code sent', body:{ challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
{ status:400, desc:'Wrong password or unavailable channel', body:{ message:'Incorrect password' }},
]},
{ method:'POST', path:'/api/users/2fa/confirm', auth:'user+', desc:'Finish turning on two-factor authentication with the code that was sent',
request:{ body:{ challengeId:'c0ffee00-...', code:'123456' }},
responses:[{ status:200, desc:'Enabled', body:{ twoFactorEnabled:true, twoFactorMethod:'email' }}]},
{ method:'POST', path:'/api/users/2fa/disable', auth:'user+', desc:'Turn off two-factor authentication',
request:{ body:{ password:'secret123' }},
notes:'Admins and supervisors must keep two-factor on — 403 for them.',
responses:[
{ status:200, desc:'Disabled', body:{ twoFactorEnabled:false, twoFactorMethod:null }},
{ status:403, desc:'Required for this role', body:{ message:"Two-factor authentication is required for your role and can't be turned off." }},
]},
{ method:'POST', path:'/api/users/verify-password', auth:'user+', desc:'Re-confirm own password without starting a new session',
request:{ body:{ password:'secret123' }},
responses:[
{ status:200, desc:'Correct', body:{ ok:true }},
{ status:401, desc:'Wrong password', body:{ message:'Incorrect password' }},
]},
{ 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/admin/all', auth:'admin', desc:'All events, including inactive ones' },
{ method:'GET', path:'/api/events/:id/ics', auth:'Public (optional login)', desc:'Download an .ics calendar file for an event' },
{ method:'GET', path:'/api/events/:id/attachments', auth:'Public', desc:'List the downloadable attachments of an event' },
{ method:'POST', path:'/api/events/:id/attachments', auth:'supervisor+', desc:'Upload an attachment (multipart, field "file")' },
{ method:'DELETE', path:'/api/events/:eventId/attachments/:attachmentId', auth:'supervisor+', desc:'Delete an attachment' },
{ method:'GET', path:'/api/events/attachments/status', auth:'admin', desc:'Diagnostics for the attachments pipeline' },
{ method:'POST', path:'/api/events/attachments/sync', auth:'admin', desc:'Re-sync attachment records with the files on disk' },
{ method:'POST', path:'/api/events/options/:id/variants', auth:'supervisor+', desc:'Add a variant (e.g. a size) to an event option' },
{ 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:'PUT', path:'/api/registrations/:id/options', auth:'Bearer (owner or staff)', desc:'Change the items on a registration (add/remove), within stock and paid-ticket limits' },
{ method:'POST', path:'/api/registrations/:id/coupon', auth:'Bearer (owner or staff)', desc:'Apply a coupon code to an existing registration', request:{ body:{ code:'STAFF50' }}},
{ method:'DELETE', path:'/api/registrations/:id/coupon', auth:'staff+', desc:'Remove the coupon from a registration (frees the use)' },
{ method:'GET', path:'/api/registrations/:id/forms/draft', auth:'Owner or staff', desc:'Saved draft of the attendee forms' },
{ method:'PUT', path:'/api/registrations/:id/forms/draft', auth:'Owner or staff', desc:'Save a draft of the attendee forms (not submitted)' },
{ method:'PUT', path:'/api/registrations/:id/forms/responses', auth:'supervisor+', desc:'Replace (edit) the submitted form responses of a registration' },
{ 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:'PUT', path:'/api/registrations/:id/attendees', auth:'user+', desc:'Replace the named attendees on a registration (group tickets) and choose ticket delivery (payer | each). Owner or staff. Removing an attendee whose ticket was scanned is refused.',
request:{ body:{ attendees:[{ id:'att-uuid-...', eventOptionId:'opt-uuid-...', name:'Sam Smith', email:'sam@example.com' },{ eventOptionId:'opt-uuid-...', name:'Alex Smith' }], ticketDelivery:'each' }},
responses:[{ status:200, desc:'Saved', body:{ id:'reg-uuid-...', ticketDelivery:'each', attendees:[{ id:'att-uuid-...', name:'Sam Smith', ticket:{ id:'tkt-uuid-...', isUsed:false }}] }}]},
{ method:'POST', path:'/api/registrations/:id/attendees/:attendeeId/send-form-link', auth:'user+', desc:'Email/WhatsApp a named attendee the link to fill in their own attendee form' },
{ 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' }}]},
{ method:'POST', path:'/api/registrations/:id/send-invoice', auth:'user+', desc:'(Re)generate the invoice PDF for one of the caller\'s own registrations and send it on the requested channel — email or WhatsApp. Works regardless of balance (shows "Paid in full" when nothing is owed); fails only if there is no valid email/phone on file for the chosen channel.',
notes:'The PDF\'s invoiceNo is derived, not sequential: INV-<year of registration.createdAt>-<last 6 hex chars of the registration id, uppercased>. When there is an outstanding balance, generates a fresh Yoco checkout link for the invoice\'s QR code/pay-now link on each call.',
pathParams:{ ':id':'Registration UUID' },
request:{ body:{ channel:'email' }},
responses:[
{ status:200, desc:'Sent', body:{ message:'Invoice emailed.' }},
{ status:400, desc:'No valid contact for channel', body:{ message:'No valid email address on file. Add one in your profile.' }},
{ status:403, desc:'Not the owner', body:{ message:'Not authorized to access this registration' }},
]},
]},
{ title: 'Impersonation', base: '/api/admin', endpoints: [
{ method:'GET', path:'/api/admin/audit-log', auth:'admin', desc:'Paginated admin/supervisor audit trail (?page, limit, actorId, action, from, to)' },
{ method:'POST', path:'/api/admin/impersonate/:userId', auth:'admin (2FA)', desc:'View the site as a user — read-only token, 30 minutes, audited', request:{ body:{ reason:'Ticket not showing in My Events' }}, responses:[{ status:201, desc:'Started', body:{ token:'jwt…', session:{ id:'sess-uuid', expiresAt:'2025-07-10T08:45:00.000Z' }}}, { status:403, desc:'Not allowed', body:{ message:"Admins can't be impersonated." }}]},
{ method:'POST', path:'/api/admin/impersonate/end', auth:'impersonation token', desc:'End the impersonation session' },
{ method:'POST', path:'/api/admin/impersonate/log', auth:'impersonation token', desc:'Record a page viewed during the session' },
{ method:'GET', path:'/api/admin/impersonate/current', auth:'impersonation token', desc:'The live impersonation session' },
{ method:'GET', path:'/api/admin/impersonation-sessions', auth:'admin', desc:'Impersonation sessions with their request trail' },
]},
{ title: 'Registration funnel', base: '/api/analytics', endpoints: [
{ method:'POST', path:'/api/analytics/track', auth:'Public (rate-limited)', desc:'Record a funnel step from a cookie-consented browser (always 204)',
request:{ body:{ sessionId:'random-uuid', eventId:'ev-uuid-...', stage:'view', source:'camp-2025' }}},
{ method:'GET', path:'/api/analytics/funnel', auth:'supervisor+', desc:'Funnel for ?eventIds=a,b&from=&to= — totals, conversion, daily trend, by source/device/option' },
]},
{ title: 'Post-event feedback', base: '/api/feedback', endpoints: [
{ method:'GET', path:'/api/feedback/event/:eventId', auth:'supervisor+', desc:'An event\'s feedback form, with invite and response counts' },
{ method:'PUT', path:'/api/feedback/event/:eventId', auth:'supervisor+', desc:'Create/update the feedback form (questions lock once answered)',
request:{ body:{ sendAt:'2025-07-13T09:00:00.000Z', audience:'checked_in', anonymous:false, intro:'Thanks for coming!', fields:[{ type:'rating', label:'Overall?', isRequired:true }, { type:'multiple_choice', label:'Best part', options:['Worship','Talks'] }] }}},
{ method:'DELETE', path:'/api/feedback/event/:eventId', auth:'supervisor+', desc:'Remove the feedback form' },
{ method:'POST', path:'/api/feedback/event/:eventId/send-now', auth:'supervisor+', desc:'Send the feedback form now' },
{ method:'GET', path:'/api/feedback/public/:token', auth:'Link holder', desc:'The form behind a personal feedback link' },
{ method:'POST', path:'/api/feedback/public/:token', auth:'Link holder', desc:'Submit feedback once', request:{ body:{ answers:{ 'field-uuid':'5' } }}},
{ method:'GET', path:'/api/feedback/summary', auth:'supervisor+', desc:'Feedback summary for ?eventIds=a,b (response rate, averages, breakdowns, comments)' },
]},
{ title: 'Sponsor a spot', base: '/api/sponsorships', endpoints: [
{ method:'GET', path:'/api/sponsorships/event/:eventId', auth:'Public', desc:'Sponsorship progress for an event (spots funded, goal) — never who was helped',
responses:[{ status:200, desc:'Progress', body:{ enabled:true, open:true, spotPrice:450, showProgress:true, goalSpots:20, spotsFunded:12, sponsors:9 }}]},
{ method:'POST', path:'/api/sponsorships/checkout', auth:'Public (optional login)', desc:'Start a Yoco checkout to sponsor spots or a custom amount; the money lands in the event sponsorship pool',
request:{ body:{ eventId:'ev-uuid-...', spots:2, anonymous:false, sponsorName:'The Smith family', message:'Have a great camp!', name:'Sam Smith', email:'sam@example.com' }},
responses:[{ status:200, desc:'Checkout created', body:{ redirectUrl:'https://pay.yoco.com/...', amount:900 }}]},
{ method:'GET', path:'/api/sponsorships/event/:eventId/pool', auth:'supervisor+', desc:'Sponsorship pool totals: received, assigned, remaining' },
]},
{ title: 'Rooms & groups', base: '/api/allocations', endpoints: [
{ method:'GET', path:'/api/allocations/event/:eventId', auth:'supervisor+', desc:'Allocation board: sets with groups and assignments, named attendees, possible leaders' },
{ method:'POST', path:'/api/allocations/sets', auth:'supervisor+', desc:'Create an allocation set', request:{ body:{ eventId:'ev-uuid-...', name:'Rooms', showOnTicket:true }}},
{ method:'PUT', path:'/api/allocations/sets/:id', auth:'supervisor+', desc:'Rename a set or show it on tickets' },
{ method:'DELETE', path:'/api/allocations/sets/:id', auth:'supervisor+', desc:'Delete a set and its groups' },
{ method:'POST', path:'/api/allocations/sets/:id/groups', auth:'supervisor+', desc:'Add a group', request:{ body:{ name:'Cabin 1', capacity:8, gender:'female', leaderUserId:'user-uuid-...' }}},
{ method:'PUT', path:'/api/allocations/groups/:id', auth:'supervisor+', desc:'Edit a group' },
{ method:'DELETE', path:'/api/allocations/groups/:id', auth:'supervisor+', desc:'Delete a group (its people become unassigned)' },
{ method:'PUT', path:'/api/allocations/sets/:id/assignments', auth:'supervisor+', desc:'Place (or with groupId null, unplace) an attendee; enforces capacity and gender', request:{ body:{ attendeeId:'att-uuid-...', groupId:'grp-uuid-...' }}},
{ method:'DELETE', path:'/api/allocations/sets/:id/assignments', auth:'supervisor+', desc:'Clear every assignment in a set' },
{ method:'POST', path:'/api/allocations/sets/:id/auto', auth:'supervisor+', desc:'Auto-allocate everyone not yet placed', request:{ body:{ mode:'together' }}, responses:[{ status:200, desc:'Done', body:{ placed:12, unplaced:1 }}]},
{ method:'GET', path:'/api/allocations/sets/:id/lists', auth:'supervisor+', desc:'Per-group lists for printing (?medical=true adds medical details; audited)' },
{ method:'GET', path:'/api/allocations/my-groups', auth:'staff+', desc:'Groups the signed-in leader leads, with their people' },
]},
{ title: 'Ticket transfer', base: '/api/tickets', endpoints: [
{ method:'DELETE', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Withdraw a transfer offer that has not been accepted yet' },
{ method:'GET', path:'/api/tickets/transfer-offer/:token', auth:'Link holder', desc:'The ticket offered behind a transfer link (status, event, ticket type, sender first name)' },
{ method:'POST', path:'/api/tickets/transfer-offer/:token/accept', auth:'Link holder', desc:'Accept the ticket — the transfer happens now (new QR code, ticket sent to the new holder)' },
{ method:'POST', path:'/api/tickets/transfer-offer/:token/decline', auth:'Link holder', desc:'Decline the ticket offer' },
{ method:'POST', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Give a ticket to someone else before the event (new QR code; the old one stops working; billing stays with the payer)',
request:{ body:{ name:'Nia New', email:'nia@example.com', phone:'0821234567' }},
responses:[{ status:200, desc:'Transferred', body:{ message:'Ticket transferred to Nia New…', ticketId:'ticket-uuid-...' }}, { status:400, desc:'Not allowed', body:{ message:'This ticket has already been used (or partly used), so it can’t be transferred.' }}]},
{ method:'GET', path:'/api/tickets/:id/transfers', auth:'payer, holder or staff+', desc:'Transfer history of a ticket' },
]},
{ title: 'Offline scanning', base: '/api/scan-locks', endpoints: [
{ method:'GET', path:'/api/scan-locks', auth:'staff+', desc:'Active offline locks for an event (?eventId=)' },
{ method:'POST', path:'/api/scan-locks', auth:'staff+', desc:'Lock a section (or the whole event) to this device for offline scanning',
request:{ body:{ eventId:'ev-uuid-...', sectionId:'sec-uuid-...', deviceId:'device-uuid', deviceName:'Chrome on Android' }},
responses:[{ status:201, desc:'Locked', body:{ id:'lock-uuid-...', sectionName:'Main hall' }}, { status:409, desc:'Held elsewhere', body:{ message:'"Main hall" is offline-locked by Ann on Chrome on Android.' }}]},
{ method:'GET', path:'/api/scan-locks/:id/snapshot', auth:'staff+', desc:'Tickets valid in the lock scope, for the device to keep offline (lock holder, ?deviceId=)' },
{ method:'POST', path:'/api/scan-locks/:id/sync', auth:'staff+', desc:'Upload offline scans (idempotent by clientScanId; capped at what is left on each ticket)',
request:{ body:{ deviceId:'device-uuid', scans:[{ clientScanId:'scan-uuid', qrCode:'qr-uuid', qty:1, scannedAt:'2025-07-10T08:15:00.000Z' }] }},
responses:[{ status:200, desc:'Synced', body:{ accepted:1, duplicates:0, conflicts:[] }}]},
{ method:'DELETE', path:'/api/scan-locks/:id', auth:'staff+', desc:'End offline mode (lock holder)' },
{ method:'POST', path:'/api/scan-locks/:id/force-release', auth:'supervisor+', desc:'Release a lost device\'s lock' },
{ method:'GET', path:'/api/scan-locks/reports', auth:'supervisor+', desc:'Offline sync reports for an event' },
{ method:'POST', path:'/api/scan-locks/reports/:id/review', auth:'supervisor+', desc:'Mark a sync report reviewed' },
]},
{ title: 'Waitlist', base: '/api/waitlist', endpoints: [
{ method:'POST', path:'/api/waitlist', auth:'public', desc:'Join the waitlist for a sold-out line (login needed when the event requires it)',
request:{ body:{ eventId:'ev-uuid-...', eventOptionId:'opt-uuid-...', quantity:2 }},
responses:[{ status:201, desc:'Joined', body:{ id:'wl-uuid-...', status:'waiting', ahead:3 }}, { status:400, desc:'Places available', body:{ message:'There are places available — you can register now.' }}]},
{ method:'GET', path:'/api/waitlist/mine', auth:'user+', desc:'My active waitlist entries' },
{ method:'DELETE', path:'/api/waitlist/:id', auth:'user+', desc:'Leave the waitlist' },
{ method:'GET', path:'/api/waitlist/claim/:token', auth:'public', desc:'What a claim link offers (rate-limited)' },
{ method:'GET', path:'/api/waitlist/events/:eventId', auth:'staff+', desc:'The queue for an event' },
{ method:'POST', path:'/api/waitlist/:id/offer', auth:'supervisor+', desc:'Offer places now, even over the stock limit' },
{ method:'POST', path:'/api/waitlist/:id/move', auth:'supervisor+', desc:'Move up or down the queue', request:{ body:{ direction:'up' }} },
{ method:'POST', path:'/api/waitlist/:id/remove', auth:'supervisor+', desc:'Take someone off the waitlist' },
]},
{ title: 'Parental consent', base: '/api/consent', endpoints: [
{ method:'GET', path:'/api/consent/:token', auth:'public', desc:'Consent page data for a parent/guardian (the link token is the credential; rate-limited)',
responses:[{ status:200, desc:'Success', body:{ status:'pending', attendeeName:'Sam Smith', consentText:'I give permission…', event:{ title:'Youth Camp' }, collect:{ medical:true, emergency:true, photo:true } }}, { status:404, desc:'Unknown or replaced link', body:{ message:'This consent link is not valid.' }}]},
{ method:'POST', path:'/api/consent/:token', auth:'public', desc:'Give or decline consent. Records name, time, IP, device and the consent text version; medical/emergency details are stored encrypted.',
request:{ body:{ decision:'give', signedName:'Pat Smith', isGuardian:true, medical:{ allergies:'Peanuts' }, emergency:{ name:'Gran', phone:'0821234567' }, photoConsent:true }},
responses:[{ status:200, desc:'Answered', body:{ status:'given' }}, { status:409, desc:'Already answered', body:{ message:'Consent has already been given.' }}]},
{ method:'GET', path:'/api/consent/events/:eventId', auth:'staff+', desc:'Consent status for every attendee on an event' },
{ method:'POST', path:'/api/consent/requests/:id/resend', auth:'user+', desc:'Re-send a pending request with a fresh link (registrant or staff)' },
{ method:'POST', path:'/api/consent/events/:eventId/resend-pending', auth:'supervisor+', desc:'Re-send every pending request on an event' },
{ method:'POST', path:'/api/consent/events/:eventId/rerequest', auth:'supervisor+', desc:'Ask again where consent was given on an older version of the text, or never requested' },
{ method:'GET', path:'/api/consent/events/:eventId/medical', auth:'supervisor+', desc:'Decrypted medical & emergency list; every view is written to the admin audit log' },
]},
{ title: 'Coupons', base: '/api/coupons', endpoints: [
{ method:'POST', path:'/api/coupons/validate', auth:'public', desc:'Check a code against a basket before registering; returns the discount. Login needed for restricted codes; staff can pass forUserId (or null for a walk-in).',
request:{ body:{ code:'STAFF50', eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }},
responses:[
{ status:200, desc:'Valid (bundle deals come off first, then the code)', body:{ valid:true, code:'STAFF50', type:'percent', value:50, summary:'50% off', discount:450, bundleDiscount:0, bundles:[], subtotal:900, total:450 }},
{ status:400, desc:'Not usable', body:{ valid:false, message:'This code has expired.' }},
]},
{ method:'GET', path:'/api/coupons', auth:'supervisor+', desc:'List codes with use counts and total discount given',
responses:[{ status:200, desc:'Success', body:[{ id:'cpn-uuid-...', code:'STAFF50', type:'percent', value:50, summary:'50% off', active:true, uses:3, totalDiscount:1350 }]}]},
{ method:'POST', path:'/api/coupons', auth:'supervisor+', desc:'Create a code. type: percent | fixed | comp. eventIds/options/userIds/restrictRoles/restrictDomain are optional.',
request:{ body:{ code:'STAFF50', type:'percent', value:50, eventIds:['ev-uuid-...'], restrictRoles:['staff','supervisor','admin'], maxUsesPerUser:1, stackEarlyBird:false }},
responses:[{ status:201, desc:'Created', body:{ id:'cpn-uuid-...', code:'STAFF50' }}, { status:409, desc:'Code exists', body:{ message:'The code STAFF50 already exists' }}]},
{ method:'GET', path:'/api/coupons/:id', auth:'supervisor+', desc:'One code with every use (redemption)' },
{ method:'PUT', path:'/api/coupons/:id', auth:'supervisor+', desc:'Edit a code or turn it off ({ active:false }). Discounts already given keep their amount; a used code can\'t be renamed.' },
{ method:'GET', path:'/api/coupons/redemptions', auth:'supervisor+', desc:'Active uses for the Discounts report. Query: eventIds (comma-separated), from, to' },
]},
{ title: 'Bundle deals', base: '/api/bundles', endpoints: [
{ method:'POST', path:'/api/bundles/quote', auth:'public', desc:'The bundle deals a basket gets, applied automatically as many times as they fit and only when cheaper. Staff can pass forUserId (or null for a walk-in).',
request:{ body:{ eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-adult', quantity:5 }, { eventOptionId:'opt-child', quantity:3 }] }},
responses:[{ status:200, desc:'Success', body:{ subtotal:550, bundleDiscount:50, bundles:[{ bundleId:'bnd-uuid-...', name:'Family deal', count:1, discount:50 }], total:500 }}]},
{ method:'GET', path:'/api/bundles/event/:eventId', auth:'supervisor+', desc:'An event\'s bundles with uses and total discount given' },
{ method:'POST', path:'/api/bundles/event/:eventId', auth:'supervisor+', desc:'Create a bundle. items: [{ eventOptionId, variantId?, quantity }] (variantId left out = any variant).',
request:{ body:{ name:'Family deal', price:500, items:[{ eventOptionId:'opt-adult', quantity:5 }, { eventOptionId:'opt-child', quantity:3 }], validUntil:null }}},
{ method:'PUT', path:'/api/bundles/:id', auth:'supervisor+', desc:'Edit a bundle or switch it off ({ isActive:false }). Registrations keep a bundle they already have.' },
{ method:'DELETE', path:'/api/bundles/:id', auth:'supervisor+', desc:'Delete a bundle; one that has been used is switched off instead' },
]},
{ title: 'Payments', base: '/api/payments', endpoints: [
{ method:'GET', path:'/api/payments/:id', auth:'Bearer', desc:'A payment by id' },
{ method:'GET', path:'/api/payments/registration/:registrationId', auth:'Bearer', desc:'Payments of a registration' },
{ method:'POST', path:'/api/payments/yoco-checkout/send', auth:'supervisor+', desc:'Send a generated Yoco payment link to the registrant by email or WhatsApp', request:{ body:{ registrationId:'reg-uuid-...', redirectUrl:'https://pay.yoco.com/...', channel:'whatsapp' }}},
{ method:'POST', path:'/api/payments/unassign-donation', auth:'supervisor+', desc:'Reverse a donation assignment (removes the leg and reverts the registration status)' },
{ 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.example.com/payment/success', cancelUrl:'https://events.example.com/payment/cancel', failureUrl:'https://events.example.com/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:'POST', path:'/api/payments/:id/send-receipt', auth:'user+', desc:'Regenerate the receipt PDF for one of the caller\'s own payments and send it on the requested channel — email or WhatsApp. Fails if there is no valid email/phone on file for the chosen channel.',
notes:'The PDF\'s receiptNo is derived, not sequential: RCPT-<year of payment.createdAt>-<last 6 hex chars of the payment id, uppercased>.',
pathParams:{ ':id':'Payment UUID' },
request:{ body:{ channel:'whatsapp' }},
responses:[
{ status:200, desc:'Sent', body:{ message:'Receipt sent to WhatsApp.' }},
{ status:403, desc:'Not the owner', body:{ message:'Not authorized to access this payment' }},
]},
{ 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', auth:'supervisor+', desc:'All tickets' },
{ method:'GET', path:'/api/tickets/:id', auth:'Bearer (owner or staff)', desc:'A ticket by id' },
{ method:'GET', path:'/api/tickets/qr/:qrCode', auth:'staff+', desc:'A ticket by QR code' },
{ method:'PUT', path:'/api/tickets/email-sent', auth:'admin', desc:'Mark tickets as emailed' },
{ 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 <john@example.com>', 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/excel', auth:'Bearer', desc:'Build an Excel file from a report payload' },
{ method:'POST', path:'/api/reports/whatsapp', auth:'Bearer', desc:'Send a report PDF by WhatsApp' },
{ 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:'<binary PDF>' }]},
{ 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/cash-by-user', auth:'supervisor+', desc:'Payments recorded for an event, by staff member and method' },
{ method:'PUT', path:'/api/cashups/event/:eventId/person-cash/:userId', auth:'supervisor+', desc:'Enter one staff member\'s physical cash count for an event' },
{ 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/logo', auth:'admin (open during first-run setup)', desc:'Upload the site logo (multipart, field "image")' },
{ method:'POST', path:'/api/uploads/favicon', auth:'admin (open during first-run setup)', desc:'Upload the site favicon (multipart, field "image")' },
{ 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:'POST', path:'/api/whatsapp/start', auth:'admin', desc:'Start (or restart) the WAWP WhatsApp instance' },
{ method:'POST', path:'/api/whatsapp/request-code', auth:'admin', desc:'Request a pairing code to link a WhatsApp number' },
{ method:'POST', path:'/api/whatsapp/delete-instance', auth:'admin', desc:'Delete the WAWP instance' },
{ method:'POST', path:'/api/whatsapp/webhook', auth:'—', desc:'WAWP event webhook (alias of /api/webhooks/whatsapp)' },
{ 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:'Cross Code' }},
{ 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: 'Dashboard stats', base: '/api/stats', endpoints: [
{ method:'GET', path:'/api/stats/staff', auth:'staff+', desc:'Everything the staff dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/supervisor', auth:'supervisor+', desc:'Everything the supervisor dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/admin', auth:'admin', desc:'Everything the admin dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/overview', auth:'supervisor+', desc:'Trailing-month KPIs (registrations, tickets, revenue, donations), revenue trend, top events, funnel conversion' },
]},
{ title: 'Backups', base: '/api/backups', endpoints: [
{ method:'GET', path:'/api/backups', auth:'admin', desc:'Local database backups, newest first' },
{ method:'POST', path:'/api/backups/run', auth:'admin', desc:'Run a backup now' },
{ method:'GET', path:'/api/backups/:filename/download', auth:'admin', desc:'Download a backup file' },
]},
{ 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: 'Yoco', base: '/api/yoco', endpoints: [
{ method:'GET', path:'/api/yoco/config', auth:'admin', desc:'Yoco settings with secrets masked (prefix + last 3 characters), where each comes from (db, env or none), test/live mode and the default webhook URL.',
responses:[{ status:200, desc:'Success', body:{ secretKeyMasked:'sk_test_***************aba', secretKeySource:'db', mode:'test', webhookSecretMasked:'whsec_*****************1Xz', webhookSecretSource:'db', webhookId:'sub_123', webhookUrl:'https://api.example.com/api/webhooks/yoco', configured:true }}]},
{ method:'PUT', path:'/api/yoco/config', auth:'admin', desc:'Save the secret key and/or webhook secret (stored AES-256-GCM encrypted). A new secret key is checked against Yoco first. An empty string clears the value (the .env fallback then applies).',
request:{ body:{ secretKey:'sk_test_...', webhookSecret:'whsec_...' }},
responses:[{ status:200, desc:'Saved', body:{ message:'Yoco settings saved' }}]},
{ method:'GET', path:'/api/yoco/webhooks', auth:'admin', desc:'Webhooks registered on the Yoco account, flagged isActive (its secret is the stored one) and pointsHere (URL is this server).',
responses:[{ status:200, desc:'Success', body:{ webhooks:[{ id:'sub_123', name:'hope-events', url:'https://api.example.com/api/webhooks/yoco', mode:'test', isActive:true, pointsHere:true }], defaultUrl:'https://api.example.com/api/webhooks/yoco' }}]},
{ method:'POST', path:'/api/yoco/webhooks', auth:'admin', desc:'Register a webhook at Yoco (defaults: name "hope-events", URL = BACKEND_URL/api/webhooks/yoco) and store the secret Yoco returns. The URL must be public https.',
request:{ body:{ name:'hope-events', url:'https://api.example.com/api/webhooks/yoco' }},
responses:[{ status:201, desc:'Registered', body:{ message:'Webhook registered and its secret saved', webhook:{ id:'sub_123', name:'hope-events', url:'https://api.example.com/api/webhooks/yoco', mode:'test' }, webhookSecretMasked:'whsec_*****************1Xz' }}]},
{ method:'DELETE', path:'/api/yoco/webhooks/:id', auth:'admin', desc:'Delete a webhook at Yoco. If it was the active one, the stored webhook secret is cleared too.',
responses:[{ status:200, desc:'Deleted', body:{ message:'Webhook deleted', clearedSecret:false }}]},
]},
{ title: 'Settings', base: '/api/settings', endpoints: [
{ method:'GET', path:'/api/settings', auth:'public', desc:'Public settings — org name, brand colours (primary/secondary/accent), logo URL, favicon URL, legal page slugs, registration notification email (no secrets)',
responses:[{ status:200, desc:'Success', body:{ org_name:'Cross Code', org_tagline:'Event management, made simple', primary_color:'#4F46E5', secondary_color:'#8B5CF6', accent_color:'#EC4899', logo_url:'/uploads/branding/logo-123.png', favicon_url:'/uploads/branding/favicon-123.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:'Cross Code', 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:'Cross Code', 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.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 `<span style="color:#6366f1">${m}</span>`; // key
return `<span style="color:#059669">${m}</span>`; // string value
}
if (/true|false/.test(m)) return `<span style="color:#d97706">${m}</span>`;
if (/null/.test(m)) return `<span style="color:#9ca3af">${m}</span>`;
return `<span style="color:#2563eb">${m}</span>`; // number
});
}
function codeBlock(content, lang) {
return `<pre style="background:#0f172a;color:#e2e8f0;padding:12px 14px;border-radius:6px;font-size:.75rem;overflow-x:auto;margin:0;line-height:1.6">${content}</pre>`;
}
function headerLine(key, val) {
return `<span style="color:#6366f1">${key}</span>: <span style="color:#059669">${val}</span>`;
}
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(`<span style="color:#f59e0b;font-weight:600">${ep.method}</span> <span style="color:#e2e8f0">${ep.path}</span> <span style="color:#64748b">HTTP/1.1</span>`);
if (needsAuth) reqLines.push(headerLine('Authorization','Bearer &lt;your-jwt&gt;'));
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(`<span style="color:#9ca3af">-- form field: image (binary file) --</span>`);
}
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' ? `<span style="color:#9ca3af">${r.body}</span>` : hl(r.body);
return `<div style="margin-bottom:8px">
<div style="font-size:.72rem;color:#64748b;margin-bottom:4px">
<span style="color:${statusColor};font-weight:600">HTTP ${r.status}</span>
<span style="margin-left:8px">${r.desc}</span>
</div>
${codeBlock(body)}
</div>`;
}).join('');
// Path params table
let paramsHtml = '';
if (ep.pathParams && Object.keys(ep.pathParams).length) {
const rows = Object.entries(ep.pathParams).map(([k,v]) =>
`<tr><td style="padding:3px 10px;font-family:monospace;color:#6366f1;white-space:nowrap">${k}</td><td style="padding:3px 10px;color:#374151">${v}</td></tr>`).join('');
paramsHtml += `<div style="margin-bottom:10px"><div style="font-size:.72rem;font-weight:600;color:#374151;margin-bottom:4px;text-transform:uppercase;letter-spacing:.05em">Path parameters</div><table style="width:100%;font-size:.8rem;border-collapse:collapse"><tbody>${rows}</tbody></table></div>`;
}
if (ep.queryParams && Object.keys(ep.queryParams).length) {
const rows = Object.entries(ep.queryParams).map(([k,v]) =>
`<tr><td style="padding:3px 10px;font-family:monospace;color:#6366f1;white-space:nowrap">${k}</td><td style="padding:3px 10px;color:#374151">${v}</td></tr>`).join('');
paramsHtml += `<div style="margin-bottom:10px"><div style="font-size:.72rem;font-weight:600;color:#374151;margin-bottom:4px;text-transform:uppercase;letter-spacing:.05em">Query parameters</div><table style="width:100%;font-size:.8rem;border-collapse:collapse"><tbody>${rows}</tbody></table></div>`;
}
const notesHtml = ep.notes
? `<div style="margin-bottom:10px;padding:8px 12px;background:#fefce8;border:1px solid #fde68a;border-radius:6px;font-size:.78rem;color:#92400e">${ep.notes}</div>`
: '';
const detailHtml = hasDetail ? `
<tr id="${id}-detail" style="display:none">
<td colspan="4" style="padding:0;border-bottom:2px solid #e5e7eb">
<div style="padding:16px;background:#f8fafc">
${notesHtml}
${paramsHtml}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div>
<div style="font-size:.72rem;font-weight:600;color:#374151;margin-bottom:6px;text-transform:uppercase;letter-spacing:.05em">Request</div>
${reqHtml}
</div>
<div>
<div style="font-size:.72rem;font-weight:600;color:#374151;margin-bottom:6px;text-transform:uppercase;letter-spacing:.05em">Responses</div>
${resHtml}
</div>
</div>
</div>
</td>
</tr>` : '';
const cursor = hasDetail ? 'cursor:pointer' : '';
const chevron = hasDetail
? `<svg id="${id}-chevron" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="display:inline;vertical-align:middle;transition:transform .15s;flex-shrink:0"><polyline points="9 18 15 12 9 6"/></svg>`
: '';
const onclick = hasDetail ? `onclick="toggle('${id}')"` : '';
return `
<tr ${onclick} style="${cursor};${hasDetail?'':''}">
<td style="padding:7px 10px"><span class="method ${methodCls}">${ep.method}</span></td>
<td style="padding:7px 10px;font-family:monospace;font-size:.8rem;word-break:break-all">${ep.path}</td>
<td style="padding:7px 10px"><span class="auth-badge">${ep.auth}</span></td>
<td style="padding:7px 10px;font-size:.82rem;display:flex;align-items:center;justify-content:space-between;gap:6px">
<span>${ep.desc}</span>
<span style="color:#9ca3af;flex-shrink:0">${chevron}</span>
</td>
</tr>
${detailHtml}`;
}
function renderGroup(group) {
const rows = group.endpoints.map((ep, i) => renderEndpoint(ep, `${group.title.replace(/\W+/g,'')}-${i}`)).join('');
return `
<h2 style="font-size:1rem;font-weight:600;color:#111827;margin:24px 0 8px">${group.title} <span style="font-size:.78rem;font-weight:400;color:#6b7280;font-family:monospace">${group.base}</span></h2>
<div style="border:1px solid #e5e7eb;border-radius:10px;overflow:hidden;margin-bottom:4px">
<table style="width:100%;border-collapse:collapse">
<thead><tr style="background:#f9fafb;border-bottom:1px solid #e5e7eb">
<th style="padding:7px 10px;width:75px;text-align:left;font-size:.75rem;color:#6b7280;font-weight:600;text-transform:uppercase;letter-spacing:.04em">Method</th>
<th style="padding:7px 10px;text-align:left;font-size:.75rem;color:#6b7280;font-weight:600;text-transform:uppercase;letter-spacing:.04em">Path</th>
<th style="padding:7px 10px;width:100px;text-align:left;font-size:.75rem;color:#6b7280;font-weight:600;text-transform:uppercase;letter-spacing:.04em">Auth</th>
<th style="padding:7px 10px;text-align:left;font-size:.75rem;color:#6b7280;font-weight:600;text-transform:uppercase;letter-spacing:.04em">Description</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
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 {org}!', 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 {org} 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 {org} 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 {org} 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 {org} 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 `<span style="display:inline-block;margin:1px 4px 2px 0;padding:1px 7px;border-radius:4px;font-size:.68rem;font-weight:700;white-space:nowrap;background:${isWa ? '#d1fae5' : '#dbeafe'};color:${isWa ? '#065f46' : '#1e40af'}">${c}</span>`;
}).join('');
}
function renderNotificationRow(n) {
return `<tr>
<td style="padding:8px 10px;font-size:.8rem;color:#374151;vertical-align:top">${n.trigger}</td>
<td style="padding:8px 10px;vertical-align:top">${notifChannelBadges(n.channels)}</td>
<td style="padding:8px 10px;font-size:.78rem;color:#374151;vertical-align:top">${n.recipients}</td>
<td style="padding:8px 10px;font-size:.78rem;font-family:monospace;color:#111827;vertical-align:top">${n.subject}</td>
<td style="padding:8px 10px;font-size:.76rem;color:#6b7280;vertical-align:top">${n.content}</td>
</tr>`;
}
function renderNotificationCategory(cat) {
const rows = cat.items.map(renderNotificationRow).join('');
return `
<h3 style="font-size:.88rem;font-weight:600;color:#111827;margin:16px 0 8px">${cat.category}</h3>
<div style="border:1px solid #e5e7eb;border-radius:10px;overflow:hidden;margin-bottom:4px">
<table style="width:100%;border-collapse:collapse">
<thead><tr style="background:#f9fafb;border-bottom:1px solid #e5e7eb">
<th style="padding:7px 10px;text-align:left;font-size:.7rem;color:#6b7280;font-weight:600;text-transform:uppercase;letter-spacing:.04em;width:26%">Trigger — when it's sent</th>
<th style="padding:7px 10px;text-align:left;font-size:.7rem;color:#6b7280;font-weight:600;text-transform:uppercase;letter-spacing:.04em;width:11%">Channel</th>
<th style="padding:7px 10px;text-align:left;font-size:.7rem;color:#6b7280;font-weight:600;text-transform:uppercase;letter-spacing:.04em;width:19%">Recipients</th>
<th style="padding:7px 10px;text-align:left;font-size:.7rem;color:#6b7280;font-weight:600;text-transform:uppercase;letter-spacing:.04em;width:17%">Subject</th>
<th style="padding:7px 10px;text-align:left;font-size:.7rem;color:#6b7280;font-weight:600;text-transform:uppercase;letter-spacing:.04em">Content</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
const notificationsHtml = NOTIFICATIONS.map(renderNotificationCategory).join('');
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${orgName} Events — API Docs</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f3f4f6;color:#1f2937;min-height:100vh;padding:24px 16px}
.wrap{max-width:1100px;margin:0 auto}
.method{display:inline-block;padding:1px 7px;border-radius:4px;font-size:.7rem;font-weight:700;font-family:monospace}
.get{background:#dbeafe;color:#1e40af}
.post{background:#d1fae5;color:#065f46}
.put{background:#fef3c7;color:#92400e}
.patch{background:#ede9fe;color:#5b21b6}
.del{background:#fee2e2;color:#991b1b}
.auth-badge{font-size:.68rem;padding:1px 6px;border-radius:4px;background:#f3f4f6;color:#374151;font-family:monospace}
code{background:#f3f4f6;padding:1px 5px;border-radius:4px;font-family:monospace;font-size:.82rem}
tr.ep-open{background:#f0f9ff}
tbody tr:hover{background:#f8fafc}
tbody tr[onclick]:hover{background:#f0f9ff}
td{border-bottom:1px solid #f3f4f6}
a{color:#2563eb;text-decoration:none}
a:hover{text-decoration:underline}
</style>
</head>
<body>
<div class="wrap">
<div style="display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:6px">
<h1 style="font-size:1.4rem;font-weight:700;color:#111827">${orgName} Events — API Reference</h1>
<a href="/" style="font-size:.82rem;color:#6b7280">&#8592; Status page</a>
</div>
<p style="font-size:.82rem;color:#6b7280;margin-bottom:20px">
Logged in as <strong>${user.name || user.email}</strong> (${user.role}) &mdash;
Click any row to expand request &amp; response examples.
</p>
<div style="background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;padding:10px 14px;font-size:.8rem;color:#1e40af;margin-bottom:20px">
Protected routes require <code>Authorization: Bearer &lt;jwt&gt;</code> &nbsp;|&nbsp;
Roles: <code>admin</code> &gt; <code>supervisor</code> &gt; <code>staff</code> &gt; <code>user</code> &nbsp;|&nbsp;
Base URL: <code>${process.env.APP_BASE_URL || 'http://localhost:' + PORT}</code>
</div>
${groupsHtml}
<h1 style="font-size:1.2rem;font-weight:700;color:#111827;margin:36px 0 4px">Notifications sent by the system</h1>
<p style="font-size:.8rem;color:#6b7280;margin-bottom:16px">
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 <code>reg_notification_emails</code> setting plus each
event's configured notify recipients (Admin &rarr; Events &rarr; edit event &rarr; Notifications step) &mdash;
falling back to that event's creator when no recipients have been chosen.
</p>
${notificationsHtml}
<p style="font-size:.72rem;color:#9ca3af;margin-top:28px;text-align:center">
${orgName} Events API v${API_VERSION} &mdash; ${new Date().toISOString()}
</p>
</div>
<script>
function toggle(id) {
const detail = document.getElementById(id + '-detail');
const chevron = document.getElementById(id + '-chevron');
if (!detail) return;
const open = detail.style.display !== 'none';
detail.style.display = open ? 'none' : 'table-row';
if (chevron) chevron.style.transform = open ? 'rotate(0deg)' : 'rotate(90deg)';
// highlight parent row
const rows = detail.parentElement.querySelectorAll('tr[onclick]');
rows.forEach(r => {
if (r.getAttribute('onclick') === "toggle('" + id + "')") {
r.classList.toggle('ep-open', !open);
}
});
}
</script>
</body>
</html>`;
res.send(html);
});
// Error middleware
app.use(notFound);
// Sentry captures the error here, then passes it through unchanged — errorHandler
// below remains the sole source of what's actually sent back to the client.
if (process.env.SENTRY_DSN) {
const Sentry = require('@sentry/node');
Sentry.setupExpressErrorHandler(app);
}
app.use(errorHandler);
// Start server
app.listen(PORT, () => {
console.log(`Server is running at http://localhost:${PORT}`);
// Attempt a one-time background sync of attachment manifests into DB
try {
const { syncManifestsToDb } = require('./utils/attachmentsSync');
const prismaShared = require('./config/db');
setTimeout(async () => {
try {
const summary = await syncManifestsToDb(prismaShared, { dryRun: false });
if ((summary.imported || 0) > 0) {
console.log(`[attachments sync] Imported ${summary.imported} attachments from manifests (skipped ${summary.skipped}).`);
} else {
console.log('[attachments sync] No attachments imported (either none found or already in DB).');
}
} catch (e) {
console.warn('[attachments sync] Failed:', e?.message || e);
}
}, 1000);
} catch (e) {
console.warn('[attachments sync] Not scheduled:', e?.message || e);
}
// Schedule daily summaries at 07:00 local time (configurable)
try {
const enabled = String(process.env.DAILY_SUMMARY_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { sendDailyEventSummaries } = require('./utils/notifications');
function msUntilNext(hour, minute) {
const now = new Date();
const next = new Date(now);
next.setHours(hour, minute, 0, 0);
if (next <= now) {
next.setDate(next.getDate() + 1);
}
return next.getTime() - now.getTime();
}
async function scheduleNext() {
const delay = msUntilNext(7, 0);
setTimeout(async () => {
try {
console.log('[daily summaries] Running daily event summaries...');
await sendDailyEventSummaries(new Date());
console.log('[daily summaries] Completed sending daily event summaries.');
} catch (e) {
console.error('[daily summaries] Failed:', e?.message || e);
} finally {
// Schedule next run
scheduleNext();
}
}, delay);
}
scheduleNext();
console.log('[daily summaries] Scheduler initialized (07:00 local time). Set DAILY_SUMMARY_ENABLED=false to disable.');
} else {
console.log('[daily summaries] Scheduler disabled by env DAILY_SUMMARY_ENABLED=false');
}
} catch (e) {
console.warn('[daily summaries] Not scheduled:', e?.message || e);
}
// Waitlist job — expire unclaimed offers and offer any free places to the next people.
try {
const enabled = String(process.env.WAITLIST_JOB_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { runWaitlistJob } = require('./utils/waitlist');
const everyMs = parseInt(process.env.WAITLIST_JOB_INTERVAL_MS || String(10 * 60 * 1000), 10);
setInterval(async () => {
try {
const r = await runWaitlistJob();
if (r.expired || r.offers) console.log(`[waitlist] ${r.expired} event(s) with expired offers, ${r.offers} new offer(s).`);
} catch (e) {
console.warn('[waitlist] Job failed:', e?.message || e);
}
}, everyMs);
console.log(`[waitlist] Job every ${Math.round(everyMs / 60000)} min.`);
} else {
console.log('[waitlist] Job disabled by env WAITLIST_JOB_ENABLED=false');
}
} catch (e) {
console.warn('[waitlist] Not scheduled:', e?.message || e);
}
// Funnel analytics — once a day, roll raw rows older than 90 days into daily totals.
try {
const enabled = String(process.env.ANALYTICS_ROLLUP_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { rollupOldEvents } = require('./utils/analytics');
const run = async () => {
try {
const n = await rollupOldEvents();
if (n) console.log(`[analytics] Rolled up ${n} session(s) older than 90 days.`);
} catch (e) {
console.warn('[analytics] Rollup failed:', e?.message || e);
}
};
setTimeout(run, 5 * 60 * 1000);
setInterval(run, 24 * 60 * 60 * 1000);
console.log('[analytics] Rollup daily.');
} else {
console.log('[analytics] Rollup disabled by env ANALYTICS_ROLLUP_ENABLED=false');
}
} catch (e) {
console.warn('[analytics] Not scheduled:', e?.message || e);
}
// Feedback job — send post-event feedback forms at their send time, and the one
// reminder 3 days later.
try {
const enabled = String(process.env.FEEDBACK_JOB_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { runFeedbackJob } = require('./utils/feedback');
const everyMs = parseInt(process.env.FEEDBACK_JOB_INTERVAL_MS || String(15 * 60 * 1000), 10);
setInterval(async () => {
try {
const r = await runFeedbackJob();
if (r.sent || r.reminded) console.log(`[feedback] ${r.sent} invite(s), ${r.reminded} reminder(s) sent.`);
} catch (e) {
console.warn('[feedback] Job failed:', e?.message || e);
}
}, everyMs);
console.log(`[feedback] Job every ${Math.round(everyMs / 60000)} min.`);
} else {
console.log('[feedback] Job disabled by env FEEDBACK_JOB_ENABLED=false');
}
} catch (e) {
console.warn('[feedback] Not scheduled:', e?.message || e);
}
// Daily parental-consent job — reminders for unanswered requests, and purging
// medical/emergency details a set number of days after each event.
try {
const enabled = String(process.env.CONSENT_JOBS_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { sendConsentReminders, purgeConsentMedicalData } = require('./utils/consent');
const runConsentJobs = async () => {
try {
const reminded = await sendConsentReminders(new Date());
const purged = await purgeConsentMedicalData(new Date());
if (reminded || purged) console.log(`[consent] Sent ${reminded} reminder(s), purged medical details on ${purged} request(s).`);
} catch (e) {
console.warn('[consent] Daily job failed:', e?.message || e);
}
};
const scheduleConsent = () => {
const now = new Date();
const next = new Date(now);
next.setHours(9, 0, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
setTimeout(async () => { await runConsentJobs(); scheduleConsent(); }, next.getTime() - now.getTime());
};
scheduleConsent();
console.log('[consent] Scheduler initialized (09:00 local time).');
} else {
console.log('[consent] Scheduler disabled by env CONSENT_JOBS_ENABLED=false');
}
} catch (e) {
console.warn('[consent] Not scheduled:', e?.message || e);
}
// Daily temp-file cleanup — removes files older than 7 days from /temp
try {
const { cleanupTempFiles } = require('./utils/cleanupTemp');
function scheduleTempCleanup() {
const now = new Date();
const next = new Date(now);
next.setHours(3, 0, 0, 0); // Run at 03:00 local time (low-traffic window)
if (next <= now) next.setDate(next.getDate() + 1);
setTimeout(() => {
try {
const result = cleanupTempFiles();
if (result.deleted > 0 || result.errors > 0) {
console.log(`[temp cleanup] Deleted ${result.deleted} file(s), ${result.errors} error(s).`);
}
} catch (e) {
console.warn('[temp cleanup] Failed:', e?.message || e);
} finally {
scheduleTempCleanup();
}
}, next.getTime() - now.getTime());
}
scheduleTempCleanup();
console.log('[temp cleanup] Scheduler initialized (03:00 local time, files older than 7 days).');
} catch (e) {
console.warn('[temp cleanup] Not scheduled:', e?.message || e);
}
// Nightly database backup at 02:00 local time (before the 03:00 temp cleanup)
try {
const enabled = String(process.env.BACKUP_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { runBackup } = require('./utils/backupUtils');
function scheduleNightlyBackup() {
const now = new Date();
const next = new Date(now);
next.setHours(2, 0, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
setTimeout(async () => {
try {
const result = await runBackup();
console.log(`[backup] Completed: ${result.filename}`);
} catch (e) {
console.error('[backup] Failed:', e?.message || e);
} finally {
scheduleNightlyBackup();
}
}, next.getTime() - now.getTime());
}
scheduleNightlyBackup();
console.log('[backup] Scheduler initialized (02:00 local time). Set BACKUP_ENABLED=false to disable.');
} else {
console.log('[backup] Scheduler disabled by env BACKUP_ENABLED=false');
}
} catch (e) {
console.warn('[backup] Not scheduled:', e?.message || e);
}
// Scheduled emails worker (polling)
try {
const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { getDueJobs, updateJob, purgeSentJobs } = require('./utils/scheduledEmails');
const { emailEventAttendees, whatsappEventAttendees } = require('./controllers/eventController');
const { sendBroadcast } = require('./controllers/broadcastController');
const { sendWhatsAppBroadcast } = require('./controllers/whatsappBroadcastController');
const intervalMs = parseInt(process.env.SCHEDULED_EMAILS_INTERVAL_MS || '30000', 10);
setInterval(async () => {
try {
try { purgeSentJobs(24 * 60 * 60 * 1000); } catch (e) { console.warn('[scheduled emails] purge failed:', e?.message || e); }
const due = getDueJobs(new Date());
if (!due || due.length === 0) return;
for (const job of due) {
try {
updateJob(job.id, { status: 'sending', attempts: (job.attempts || 0) + 1, lastError: null });
let responseBody = null; let code = 200;
const mockRes = {
status: (c) => { code = c; return mockRes; },
json: (b) => { responseBody = b; return mockRes; }
};
if (job.broadcast && job.channel === 'whatsapp') {
const mockReq = { body: job.payload };
await sendWhatsAppBroadcast(mockReq, mockRes);
} else if (job.broadcast) {
const mockReq = { body: job.payload };
await sendBroadcast(mockReq, mockRes);
} else if (job.channel === 'whatsapp') {
const mockReq = { params: { id: job.eventId }, body: job.payload };
await whatsappEventAttendees(mockReq, mockRes);
} else {
const mockReq = { params: { id: job.eventId }, body: job.payload };
await emailEventAttendees(mockReq, mockRes);
}
if (code >= 200 && code < 300) {
updateJob(job.id, { status: 'sent', sentAt: new Date().toISOString(), lastResult: responseBody || null });
} else {
updateJob(job.id, { status: 'error', lastError: (responseBody && responseBody.message) ? responseBody.message : `HTTP ${code}` });
}
} catch (e) {
updateJob(job.id, { status: 'error', lastError: String(e?.message || e) });
}
}
} catch (e) {
console.warn('[scheduled emails] poll failed:', e?.message || e);
}
}, isNaN(intervalMs) ? 30000 : intervalMs);
console.log('[scheduled emails] Worker initialized. Set SCHEDULED_EMAILS_ENABLED=false to disable.');
} else {
console.log('[scheduled emails] Worker disabled by env SCHEDULED_EMAILS_ENABLED=false');
}
} catch (e) {
console.warn('[scheduled emails] Worker not started:', e?.message || e);
}
});
// Log unhandled promise rejections without killing the server, since a single
// missed .catch() on fire-and-forget notification code (email/WhatsApp sends)
// would otherwise take the whole app down.
process.on('unhandledRejection', (err) => {
console.error('UNHANDLED REJECTION!', err?.name, err?.message);
console.error(err?.stack || err);
if (process.env.SENTRY_DSN) {
try { require('@sentry/node').captureException(err); } catch {}
}
});
module.exports = { app, prisma };