Per feedback: card-network wallet payments (Apple Pay, Google Pay) should report and filter as "card" on the user payment history page rather than "other", since they settle the same way as a card payment. Any other gateway-reported method still falls under "other". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1254 lines
88 KiB
JavaScript
1254 lines
88 KiB
JavaScript
const express = require('express');
|
||
const path = require('path');
|
||
const { version: API_VERSION } = require('../package.json');
|
||
const cors = require('cors');
|
||
const rateLimit = require('express-rate-limit');
|
||
const dotenv = require('dotenv');
|
||
const { PrismaClient } = require('@prisma/client');
|
||
const { notFound, errorHandler } = require('./middleware/errorMiddleware');
|
||
const getRawBody = require('raw-body');
|
||
|
||
// Load environment variables
|
||
dotenv.config();
|
||
|
||
// Initialize Prisma client
|
||
const prisma = new PrismaClient();
|
||
|
||
// Initialize Express app
|
||
const app = express();
|
||
const PORT = process.env.PORT || 3000;
|
||
|
||
// CORS — allow only the configured frontend origin
|
||
const allowedOrigins = (process.env.FRONTEND_URL || 'http://localhost:3000')
|
||
.split(',')
|
||
.map((o) => o.trim());
|
||
|
||
const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV;
|
||
const isTesting = process.env.NODE_ENV === 'testing';
|
||
const isProd = process.env.NODE_ENV === 'production';
|
||
const modeLabel = isProd ? 'production' : isTesting ? 'testing' : 'development';
|
||
console.log(`Running in ${modeLabel} mode.`);
|
||
|
||
// In testing or production, require all critical env vars to be set at startup
|
||
if (isTesting || isProd) {
|
||
const required = ['DATABASE_URL', 'JWT_SECRET'];
|
||
const missing = required.filter(k => !process.env[k]);
|
||
if (missing.length > 0) {
|
||
console.error(`[startup] Missing required environment variables for ${modeLabel} mode: ${missing.join(', ')}`);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
app.use(cors({
|
||
origin: (origin, callback) => {
|
||
// Allow requests with no origin (e.g. mobile apps, curl, Postman)
|
||
if (!origin) return callback(null, true);
|
||
// Always allow configured origins
|
||
if (allowedOrigins.includes(origin)) return callback(null, true);
|
||
// In dev or testing, also allow any LAN origin on the same port (e.g. phone on 192.168.x.x:3000)
|
||
if (isDev || isTesting) {
|
||
try {
|
||
const u = new URL(origin);
|
||
const isLan = /^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(u.hostname);
|
||
if (isLan) return callback(null, true);
|
||
} catch {}
|
||
}
|
||
callback(new Error(`CORS: origin '${origin}' not allowed`));
|
||
},
|
||
credentials: true,
|
||
}));
|
||
|
||
// Global rate limiter — 5000 requests per 5 minutes per IP
|
||
// Webhooks are excluded because they come from Yoco's servers and are already HMAC-verified
|
||
const globalLimiter = rateLimit({
|
||
windowMs: 5 * 60 * 1000,
|
||
max: 5000,
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
message: { message: 'Too many requests, please try again later.' },
|
||
skip: (req) => req.path.startsWith('/api/webhooks'),
|
||
});
|
||
app.use(globalLimiter);
|
||
|
||
// Raw body parsing middleware for webhooks
|
||
app.use((req, res, next) => {
|
||
if (req.path.startsWith('/api/webhooks')) {
|
||
getRawBody(req, {
|
||
length: req.headers['content-length'],
|
||
encoding: 'utf-8'
|
||
}, (err, rawBody) => {
|
||
if (err) return next(err);
|
||
req.rawBody = rawBody;
|
||
next();
|
||
});
|
||
} else {
|
||
next();
|
||
}
|
||
});
|
||
|
||
|
||
// Import routes
|
||
const userRoutes = require('./routes/userRoutes');
|
||
const eventRoutes = require('./routes/eventRoutes');
|
||
const registrationRoutes = require('./routes/registrationRoutes');
|
||
const paymentRoutes = require('./routes/paymentRoutes');
|
||
const ticketRoutes = require('./routes/ticketRoutes');
|
||
const webhookRoutes = require('./routes/webhookRoutes');
|
||
const uploadRoutes = require('./routes/uploadRoutes');
|
||
const reportRoutes = require('./routes/reportRoutes');
|
||
const formRoutes = require('./routes/formRoutes');
|
||
const yocoTransactionRoutes = require('./routes/yocoTransactionRoutes');
|
||
const automationRoutes = require('./routes/automationRoutes');
|
||
const broadcastRoutes = require('./routes/broadcastRoutes');
|
||
const whatsappBroadcastRoutes = require('./routes/whatsappBroadcastRoutes');
|
||
const scheduledEmailRoutes = require('./routes/scheduledEmailRoutes');
|
||
const sectionRoutes = require('./routes/sectionRoutes');
|
||
const bannerRoutes = require('./routes/bannerRoutes');
|
||
const whatsappRoutes = require('./routes/whatsappRoutes');
|
||
const settingsRoutes = require('./routes/settingsRoutes');
|
||
const setupRoutes = require('./routes/setupRoutes');
|
||
const costRoutes = require('./routes/costRoutes');
|
||
const cashupRoutes = require('./routes/cashupRoutes');
|
||
const statsRoutes = require('./routes/statsRoutes');
|
||
|
||
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
|
||
app.use('/api/webhooks', webhookRoutes);
|
||
|
||
// Apply JSON body parser for all non-webhook routes
|
||
app.use(express.json());
|
||
|
||
// Use routes
|
||
app.use('/api/users', userRoutes);
|
||
app.use('/api/events', eventRoutes);
|
||
app.use('/api/registrations', registrationRoutes);
|
||
app.use('/api/payments', paymentRoutes);
|
||
app.use('/api/tickets', ticketRoutes);
|
||
app.use('/api/uploads', uploadRoutes);
|
||
app.use('/api/reports', reportRoutes);
|
||
app.use('/api/forms', formRoutes);
|
||
app.use('/api/yoco-transactions', yocoTransactionRoutes);
|
||
app.use('/api/automations', automationRoutes);
|
||
app.use('/api/broadcasts', broadcastRoutes);
|
||
app.use('/api/whatsapp-broadcasts', whatsappBroadcastRoutes);
|
||
app.use('/api/scheduled-emails', scheduledEmailRoutes);
|
||
app.use('/api/sections', sectionRoutes);
|
||
app.use('/api/banner', bannerRoutes);
|
||
app.use('/api/whatsapp', whatsappRoutes);
|
||
app.use('/api/settings', settingsRoutes);
|
||
app.use('/api/setup', setupRoutes);
|
||
app.use('/api/stats', statsRoutes);
|
||
app.use('/api', costRoutes);
|
||
app.use('/api/cashups', cashupRoutes);
|
||
|
||
// Pre-warm the settings cache so synchronous helpers have DB values from startup
|
||
require('./utils/settingsCache').warmCache().catch(() => {});
|
||
app.use('/uploads', express.static('public/uploads'));
|
||
|
||
// ── Shared page helpers ────────────────────────────────────────────────────────
|
||
const jwt = require('jsonwebtoken');
|
||
|
||
function pageShell(title, accentColor, bodyHtml) {
|
||
return `<!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">✓ ${dbMsg}</span>`
|
||
: `<span class="badge badge-err">✗ ${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 html = pageShell('Hope Events API — Status', '#2563eb', `
|
||
<h1>Hope Events API</h1>
|
||
<p class="subtitle">v${API_VERSION} — ${now}</p>
|
||
|
||
<div class="stat-grid">
|
||
<div class="stat">
|
||
<div class="stat-label">Status</div>
|
||
<div class="stat-value"><span class="badge badge-ok">✓ 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=<your-admin-jwt></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);
|
||
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:'POST', path:'/api/users', auth:'public', desc:'Register a new account',
|
||
request:{ body:{ name:'Jane Doe', email:'jane@example.com', password:'secret123', phoneNumber:'+27821234567' }},
|
||
responses:[
|
||
{ status:201, desc:'Created', body:{ id:'a1b2c3d4-0000-0000-0000-000000000001', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...' }},
|
||
{ status:400, desc:'Validation error', body:{ message:'Email already in use' }},
|
||
]},
|
||
{ method:'POST', path:'/api/users/login', auth:'public', desc:'Login — returns a signed JWT',
|
||
notes:'Rate-limited to 15 requests/min per IP.',
|
||
request:{ body:{ email:'jane@example.com', password:'secret123' }},
|
||
responses:[
|
||
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...' }},
|
||
{ status:401, desc:'Wrong credentials', body:{ message:'Invalid email or password' }},
|
||
]},
|
||
{ method:'POST', path:'/api/users/forgot', auth:'public', desc:'Request a password reset email',
|
||
request:{ body:{ email:'jane@example.com' }},
|
||
responses:[
|
||
{ status:200, desc:'Always succeeds (no user enumeration)', body:{ message:'If that email exists, a reset link has been sent.' }},
|
||
]},
|
||
{ method:'POST', path:'/api/users/reset', auth:'public', desc:'Reset password using token from email',
|
||
request:{ body:{ token:'reset-token-from-email', password:'newPassword123' }},
|
||
responses:[
|
||
{ status:200, desc:'Success', body:{ message:'Password reset successful.' }},
|
||
{ status:400, desc:'Invalid or expired token', body:{ message:'Reset token is invalid or has expired.' }},
|
||
]},
|
||
{ method:'POST', path:'/api/users/activate', auth:'public', desc:'Activate a new account via email link',
|
||
request:{ body:{ token:'activation-token-from-email' }},
|
||
responses:[
|
||
{ status:200, desc:'Success', body:{ message:'Account activated.' }},
|
||
{ status:400, desc:'Invalid token', body:{ message:'Invalid or expired activation token.' }},
|
||
]},
|
||
{ method:'GET', path:'/api/users/profile', auth:'user+', desc:'Get own profile',
|
||
responses:[
|
||
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', phoneNumber:'+27821234567', notificationPreference:'email', createdAt:'2024-01-15T08:00:00.000Z' }},
|
||
{ status:401, desc:'Unauthorized', body:{ message:'Not authorized, no token' }},
|
||
]},
|
||
{ method:'PUT', path:'/api/users/profile', auth:'user+', desc:'Update own profile',
|
||
request:{ body:{ name:'Jane Smith', phoneNumber:'+27821234567', notificationPreference:'both' }},
|
||
responses:[
|
||
{ status:200, desc:'Updated', body:{ id:'a1b2c3d4-...', name:'Jane Smith', email:'jane@example.com', notificationPreference:'both' }},
|
||
]},
|
||
{ method:'POST', path:'/api/users/revoke-sessions', auth:'user+', desc:'Invalidate all own sessions by incrementing tokenVersion',
|
||
responses:[{ status:200, desc:'Success', body:{ message:'All sessions revoked.' }}]},
|
||
{ method:'POST', path:'/api/users/close-account', auth:'user+', desc:'Deactivate own account',
|
||
responses:[{ status:200, desc:'Success', body:{ message:'Account deactivated.' }}]},
|
||
{ method:'GET', path:'/api/users', auth:'supervisor+', desc:'List all users (paginated). All filters are applied server-side.',
|
||
queryParams:{ page:'Page number (default 1)', limit:'Items per page (max 200, default 100)', search:'Search by name, email, or phone number (case-insensitive substring)', role:'Filter by role: admin|supervisor|staff|user', isActive:'Filter by active status: true|false (omit for both)' },
|
||
responses:[
|
||
{ status:200, desc:'Success', body:{ data:[{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', isActive:true, createdAt:'2024-01-15T08:00:00.000Z' }], total:1, page:1, limit:20 }},
|
||
]},
|
||
{ method:'GET', path:'/api/users/check-exists', auth:'supervisor+', desc:'Check whether an account already exists for a given email and/or phone number (used by the self-service kiosk and manual registration screens to avoid duplicate accounts)',
|
||
queryParams:{ email:'Email to look up (optional)', phone:'Phone number to look up (optional; SA formats normalized)' },
|
||
responses:[
|
||
{ status:200, desc:'Success', body:{ exists:true, hasEmail:true, hasPhone:false }},
|
||
]},
|
||
{ method:'GET', path:'/api/users/:id', auth:'admin', desc:'Get a single user by ID',
|
||
pathParams:{ ':id':'User UUID' },
|
||
responses:[
|
||
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', isActive:true, phoneNumber:'+27821234567', notificationPreference:'email' }},
|
||
{ status:404, desc:'Not found', body:{ message:'User not found' }},
|
||
]},
|
||
{ method:'PUT', path:'/api/users/:id', auth:'admin', desc:'Update any user (including role)',
|
||
pathParams:{ ':id':'User UUID' },
|
||
request:{ body:{ role:'supervisor', isActive:true, name:'Jane Doe' }},
|
||
responses:[
|
||
{ status:200, desc:'Updated', body:{ id:'a1b2c3d4-...', name:'Jane Doe', role:'supervisor' }},
|
||
]},
|
||
{ method:'DELETE', path:'/api/users/:id', auth:'admin', desc:'Deactivate a user account (sets isActive: false; does not erase data)',
|
||
pathParams:{ ':id':'User UUID' },
|
||
responses:[
|
||
{ status:200, desc:'Deactivated', body:{ message:'User deactivated' }},
|
||
{ status:404, desc:'Not found', body:{ message:'User not found' }},
|
||
]},
|
||
{ method:'POST', path:'/api/users/:id/revoke-sessions', auth:'admin', desc:'Revoke all sessions for another user',
|
||
pathParams:{ ':id':'User UUID' },
|
||
responses:[{ status:200, desc:'Success', body:{ message:'Sessions revoked for user.' }}]},
|
||
{ method:'POST', path:'/api/users/:id/anonymize', auth:'admin', desc:'Erase personal data for a user — sets name to "Deleted User", email to deleted-{id}@deleted.local, clears phone number, deactivates account, revokes all sessions. Irreversible.',
|
||
pathParams:{ ':id':'User UUID' },
|
||
responses:[
|
||
{ status:200, desc:'Anonymised', body:{ message:'User data deleted' }},
|
||
{ status:404, desc:'Not found', body:{ message:'User not found' }},
|
||
]},
|
||
]},
|
||
|
||
{ title: 'Events', base: '/api/events', endpoints: [
|
||
{ method:'GET', path:'/api/events', auth:'public', desc:'List active, public, upcoming events. Each event includes isSoldOut:boolean — true when every option with a stockLimit > 0 is fully booked.',
|
||
queryParams:{ limit:'Max results (default 20)' },
|
||
responses:[
|
||
{ status:200, desc:'Success', body:[{ id:'ev-uuid-...', title:'Camp 2025', startDate:'2025-07-10T08:00:00.000Z', endDate:'2025-07-14T17:00:00.000Z', price:450, picture:'/uploads/camp.jpg', isActive:true, isSoldOut:false }]},
|
||
]},
|
||
{ method:'GET', path:'/api/events/all', auth:'staff+', desc:'All events including hidden and past',
|
||
queryParams:{ includePast:'Include past events (true|false, default false)' },
|
||
responses:[{ status:200, desc:'Success', body:[{ id:'ev-uuid-...', title:'Camp 2025', isHidden:false, isActive:true }]}]},
|
||
{ method:'GET', path:'/api/events/:id', auth:'public', desc:'Get full event detail by ID. When called by staff/supervisor/admin, the response also includes createdBy and notifyRecipients (who the event\'s notifications go to).',
|
||
pathParams:{ ':id':'Event UUID' },
|
||
responses:[
|
||
{ status:200, desc:'Success', body:{ id:'ev-uuid-...', title:'Camp 2025', description:'Annual family camp.', startDate:'2025-07-10T08:00:00.000Z', endDate:'2025-07-14T17:00:00.000Z', price:450, requiresAuth:true, eventOptions:[{ id:'opt-uuid-...', name:'Adult', price:450, isMainTicket:true }], createdBy:{ id:'user-uuid-...', name:'Jane Supervisor', email:'jane@example.com' }, notifyRecipients:[]}},
|
||
{ status:404, desc:'Not found', body:{ message:'Event not found' }},
|
||
]},
|
||
{ method:'GET', path:'/api/events/by-alias/:redirectUrl', auth:'public', desc:'Get event by its URL alias',
|
||
pathParams:{ ':redirectUrl':'URL alias string (e.g. camp-2025)' },
|
||
responses:[{ status:200, desc:'Success', body:{ id:'ev-uuid-...', title:'Camp 2025', redirectUrl:'camp-2025' }}]},
|
||
{ method:'POST', path:'/api/events', auth:'supervisor+', desc:'Create a new event',
|
||
request:{ body:{ title:'Camp 2025', description:'Annual family camp', startDate:'2025-07-10T08:00:00.000Z', endDate:'2025-07-14T17:00:00.000Z', registrationDeadline:'2025-07-01T00:00:00.000Z', price:450, isActive:true, isHidden:false, requiresAuth:true }},
|
||
responses:[
|
||
{ status:201, desc:'Created', body:{ id:'ev-uuid-new', title:'Camp 2025', createdAt:'2024-11-01T09:00:00.000Z' }},
|
||
]},
|
||
{ method:'PUT', path:'/api/events/:id', auth:'supervisor+', desc:'Update an event',
|
||
pathParams:{ ':id':'Event UUID' },
|
||
request:{ body:{ title:'Camp 2025 Updated', price:500 }},
|
||
responses:[{ status:200, desc:'Updated', body:{ id:'ev-uuid-...', title:'Camp 2025 Updated', price:500 }}]},
|
||
{ method:'GET', path:'/api/events/:id/notify-recipients', auth:'supervisor+', desc:'Get which users receive registration/payment/daily-summary notifications for this event. Lightweight — skips the option/stock computation that GET /api/events/:id does.',
|
||
pathParams:{ ':id':'Event UUID' },
|
||
responses:[{ status:200, desc:'Success', body:[{ id:'user-uuid-1', name:'Jane Supervisor', email:'jane@example.com', role:'supervisor' }]}]},
|
||
{ method:'PUT', path:'/api/events/:id/notify-recipients', auth:'supervisor+', desc:'Set which users receive registration/payment/daily-summary notifications for this event. Pass an empty array to fall back to the event creator.',
|
||
pathParams:{ ':id':'Event UUID' },
|
||
request:{ body:{ userIds:['user-uuid-1','user-uuid-2'] }},
|
||
responses:[
|
||
{ status:200, desc:'Saved', body:[{ id:'user-uuid-1', name:'Jane Supervisor', email:'jane@example.com', role:'supervisor' },{ id:'user-uuid-2', name:'John Admin', email:'john@example.com', role:'admin' }]},
|
||
{ status:400, desc:'Bad input', body:{ message:'userIds must be an array' }},
|
||
]},
|
||
{ method:'DELETE', path:'/api/events/:id', auth:'admin', desc:'Permanently delete an event',
|
||
pathParams:{ ':id':'Event UUID' },
|
||
responses:[{ status:200, desc:'Deleted', body:{ message:'Event deleted.' }}]},
|
||
{ method:'POST', path:'/api/events/:id/email-attendees', auth:'supervisor+', desc:'Send a bulk email to event attendees. Use dryRun:true to preview recipients without sending.',
|
||
pathParams:{ ':id':'Event UUID' },
|
||
request:{ body:{ subject:'Payment reminder: Camp 2025', text:'Hi {{name}}, you have an outstanding balance of {{balance}}.', filter:{ status:'unpaid', attendeeIds:['user-uuid-1','user-uuid-2'] }, dryRun:false, template:'custom' }},
|
||
responses:[
|
||
{ status:200, desc:'Sent', body:{ matched:15, sent:14, failed:1 }},
|
||
{ status:200, desc:'Dry run', body:{ matched:15, dryRun:true, recipients:[{ email:'jane@example.com', name:'Jane Doe' }]}},
|
||
]},
|
||
{ method:'POST', path:'/api/events/:id/email-attendees/schedule', auth:'supervisor+', desc:'Schedule a bulk email to run at a future time',
|
||
pathParams:{ ':id':'Event UUID' },
|
||
request:{ body:{ subject:'Reminder: Camp 2025', text:'Hi {{name}}, see you soon!', scheduledAt:'2025-07-03T09:00:00.000Z', filter:{ status:'paid' }}},
|
||
responses:[{ status:201, desc:'Scheduled', body:{ job:{ id:'job-uuid-...', scheduledAt:'2025-07-03T09:00:00.000Z', status:'queued' }}}]},
|
||
{ method:'POST', path:'/api/events/:id/whatsapp-attendees', auth:'supervisor+', desc:'Send a bulk WhatsApp message to attendees with a phone number. Supports dryRun.',
|
||
pathParams:{ ':id':'Event UUID' },
|
||
request:{ body:{ message:'Hi {{name}}, camp starts in 3 days! 🏕️', filter:{ status:'paid' }, dryRun:false }},
|
||
responses:[
|
||
{ status:200, desc:'Sent', body:{ matched:12, sent:11 }},
|
||
]},
|
||
{ method:'POST', path:'/api/events/:id/whatsapp-attendees/schedule', auth:'supervisor+', desc:'Schedule a bulk WhatsApp message',
|
||
pathParams:{ ':id':'Event UUID' },
|
||
request:{ body:{ message:'Hi {{name}}, final reminder for camp tomorrow! 🎉', scheduledAt:'2025-07-09T08:00:00.000Z', filter:{ status:'paid' }}},
|
||
responses:[{ status:201, desc:'Scheduled', body:{ job:{ id:'job-uuid-...', status:'queued' }}}]},
|
||
{ method:'POST', path:'/api/events/:id/options', auth:'supervisor+', desc:'Add a ticket option to an event',
|
||
pathParams:{ ':id':'Event UUID' },
|
||
request:{ body:{ name:'Child (under 12)', price:250, isMainTicket:false }},
|
||
responses:[{ status:201, desc:'Created', body:{ id:'opt-uuid-new', name:'Child (under 12)', price:250, isMainTicket:false }}]},
|
||
{ method:'PUT', path:'/api/events/options/:id', auth:'supervisor+', desc:'Update a ticket option',
|
||
pathParams:{ ':id':'EventOption UUID' },
|
||
request:{ body:{ price:300 }},
|
||
responses:[{ status:200, desc:'Updated', body:{ id:'opt-uuid-...', price:300 }}]},
|
||
{ method:'DELETE', path:'/api/events/options/:id', auth:'admin', desc:'Delete a ticket option (fails if registrations exist)',
|
||
pathParams:{ ':id':'EventOption UUID' },
|
||
responses:[
|
||
{ status:200, desc:'Deleted', body:{ message:'Option deleted.' }},
|
||
{ status:400, desc:'In use', body:{ message:'Cannot delete option with existing registrations.' }},
|
||
]},
|
||
]},
|
||
|
||
{ title: 'Registrations', base: '/api/registrations', endpoints: [
|
||
{ method:'POST', path:'/api/registrations', auth:'optional', desc:'Create a registration. Auth optional — guests are allowed when event does not require auth.',
|
||
request:{ body:{ eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }},
|
||
responses:[
|
||
{ status:201, desc:'Created', body:{ id:'reg-uuid-...', eventId:'ev-uuid-...', status:'pending', checkoutId:null, createdAt:'2025-06-01T10:00:00.000Z' }},
|
||
{ status:400, desc:'Deadline passed', body:{ message:'Registration deadline has passed.' }},
|
||
]},
|
||
{ method:'GET', path:'/api/registrations/myregistrations', auth:'user+', desc:'Get all registrations for the authenticated user',
|
||
responses:[{ status:200, desc:'Success', body:[{ id:'reg-uuid-...', event:{ id:'ev-uuid-...', title:'Camp 2025' }, status:'paid', createdAt:'2025-06-01T10:00:00.000Z' }]}]},
|
||
{ method:'GET', path:'/api/registrations/:id', auth:'optional', desc:'Get registration detail by ID',
|
||
pathParams:{ ':id':'Registration UUID' },
|
||
responses:[{ status:200, desc:'Success', body:{ id:'reg-uuid-...', status:'paid', event:{ title:'Camp 2025' }, registrationOptions:[{ eventOption:{ name:'Adult' }, quantity:1 }], payments:[{ amount:450, method:'card', status:'succeeded' }] }}]},
|
||
{ method:'PUT', path:'/api/registrations/:id', auth:'staff+', desc:'Update registration status. If set to "paid", generates tickets and emails them fire-and-forget. If downgraded from "paid", deletes unused tickets (blocks if any ticket has been scanned).',
|
||
pathParams:{ ':id':'Registration UUID' },
|
||
request:{ body:{ status:'paid' }},
|
||
responses:[{ status:200, desc:'Updated', body:{ id:'reg-uuid-...', status:'paid' }}]},
|
||
{ method:'DELETE', path:'/api/registrations/:id', auth:'user+', desc:'Cancel registration. Users: only allowed when no positive payments exist (returns 403 otherwise). Admins: always allowed.',
|
||
pathParams:{ ':id':'Registration UUID' },
|
||
responses:[
|
||
{ status:200, desc:'Cancelled', body:{ message:'Registration cancelled', registration:{ id:'reg-uuid-...', status:'cancelled' }}},
|
||
{ status:400, desc:'Already cancelled', body:{ message:'Registration is already cancelled' }},
|
||
{ status:403, desc:'Payments exist — non-admin cannot cancel', body:{ message:'This registration has payments recorded against it and cannot be self-cancelled. Please contact the organisation for assistance.' }},
|
||
{ status:403, desc:'Not the owner (and not admin)', body:{ message:'Not authorized to cancel this registration' }},
|
||
]},
|
||
{ method:'GET', path:'/api/registrations', auth:'staff+', desc:'List all registrations across all events',
|
||
queryParams:{ page:'Page (default 1)', limit:'Per page (default 20)', eventId:'Filter by event', status:'Filter by status', search:'Search by name/email' },
|
||
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'reg-uuid-...', user:{ name:'Jane Doe' }, event:{ title:'Camp 2025' }, status:'paid' }], total:1, page:1 }}]},
|
||
{ method:'GET', path:'/api/registrations/event/:eventId', auth:'staff+', desc:'Get all registrations for a specific event',
|
||
pathParams:{ ':eventId':'Event UUID' },
|
||
responses:[{ status:200, desc:'Success', body:[{ id:'reg-uuid-...', user:{ id:'user-uuid-...', name:'Jane Doe', email:'jane@example.com', phoneNumber:'+27821234567' }, status:'paid' }]}]},
|
||
{ method:'POST', path:'/api/registrations/manual', auth:'supervisor+', desc:'Manually create a registration (at-the-door / walk-in). Resolves early-bird tier pricing (including per-variant tiers) and stores priceSnapshot + appliedTierId on each option. Creates or finds a matching user by email/phone. If a matching user is found, updates their notificationPreference and backfills a missing phone number or guest placeholder email with the newly supplied value (without overwriting existing contact info). Generates a Yoco checkout link for unpaid registrations to include in the self-service notification email.',
|
||
request:{ body:{ eventId:'ev-uuid-...', user:{ name:'Jane Doe', email:'jane@example.com', phoneNumber:'0821234567' }, options:[{ eventOptionId:'opt-uuid-...', quantity:1, variantId:'var-uuid-...' }], notificationPreference:'email' }},
|
||
responses:[{ status:201, desc:'Created', body:{ id:'reg-uuid-new', status:'pending' }}]},
|
||
{ method:'POST', path:'/api/registrations/:id/forms/responses', auth:'optional', desc:'Submit form responses for a registration',
|
||
pathParams:{ ':id':'Registration UUID' },
|
||
request:{ body:{ answers:[{ fieldId:'field-uuid-...', value:'Yes' }, { fieldId:'field-uuid-2', value:'Vegetarian' }] }},
|
||
responses:[{ status:201, desc:'Submitted', body:{ id:'response-uuid-...', createdAt:'2025-06-01T10:05:00.000Z' }}]},
|
||
]},
|
||
|
||
{ title: 'Payments', base: '/api/payments', endpoints: [
|
||
{ method:'POST', path:'/api/payments/yoco-checkout', auth:'user+', desc:'Initiate a Yoco checkout session. Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.',
|
||
request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.hopehenley.co.za/payment/success', cancelUrl:'https://events.hopehenley.co.za/payment/cancel', failureUrl:'https://events.hopehenley.co.za/payment/failure' }},
|
||
responses:[
|
||
{ status:200, desc:'Checkout created — proceed to Yoco', body:{ redirectUrl:'https://pay.yoco.com/checkout/abc123', checkoutId:'yoco-checkout-id', amount:450 }},
|
||
{ status:200, desc:'Early-bird price changed — checkout NOT created. Frontend must show warning and let user confirm before retrying.', body:{ priceUpdated:true, newTotal:500, message:'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.' }},
|
||
]},
|
||
{ method:'POST', path:'/api/payments', auth:'supervisor+', desc:'Record a manual payment (cash, EFT, etc.)',
|
||
request:{ body:{ registrationId:'reg-uuid-...', amount:450, method:'cash', userId:'user-uuid-...' }},
|
||
responses:[
|
||
{ status:201, desc:'Recorded', body:{ id:'pay-uuid-...', amount:450, method:'cash', status:'succeeded', createdAt:'2025-06-10T09:00:00.000Z' }},
|
||
]},
|
||
{ method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history (paginated, excludes donations). Returned method is normalized to cash|card|eft|voucher|other — apple_pay/google_pay report as "card", any other gateway-reported value reports as "other"',
|
||
queryParams:{ page:'Page (default 1)', limit:'Per page (default 25, max 25)', startDate:'ISO date, filters createdAt >=', endDate:'ISO date, filters createdAt <=', method:'Filter by normalized method: cash|card|eft|voucher|other', kind:'payment|refund — filters by amount sign' },
|
||
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }], total:1, page:1, limit:25, pages:1 }}]},
|
||
{ method:'GET', path:'/api/payments', auth:'supervisor+', desc:'List all payments',
|
||
queryParams:{ page:'Page (default 1)', limit:'Per page (default 20)', eventId:'Filter by event', userId:'Filter by user', method:'Filter by method (cash|card|eft|donation)', startDate:'ISO date', endDate:'ISO date' },
|
||
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', user:{ name:'Jane Doe' }, registration:{ event:{ title:'Camp 2025' }}}], total:1 }}]},
|
||
{ method:'GET', path:'/api/payments/event/:eventId', auth:'staff+', desc:'All payments for an event',
|
||
pathParams:{ ':eventId':'Event UUID' },
|
||
responses:[{ status:200, desc:'Success', body:[{ id:'pay-uuid-...', amount:450, user:{ name:'Jane Doe' }, method:'card' }]}]},
|
||
{ method:'POST', path:'/api/payments/refund', auth:'supervisor+', desc:'Create a refund payment (negative amount)',
|
||
request:{ body:{ originalPaymentId:'pay-uuid-...', amount:450, reason:'Cancelled registration' }},
|
||
responses:[{ status:201, desc:'Refund recorded', body:{ id:'pay-uuid-refund', amount:-450, method:'refund' }}]},
|
||
{ method:'PUT', path:'/api/payments/assign-donation', auth:'supervisor+', desc:'Link an unassigned donation payment to a specific registration',
|
||
request:{ body:{ paymentId:'pay-uuid-...', registrationId:'reg-uuid-...' }},
|
||
responses:[{ status:200, desc:'Assigned', body:{ id:'pay-uuid-...', registrationId:'reg-uuid-...', isDonation:false }}]},
|
||
{ method:'GET', path:'/api/payments/admin/stats', auth:'admin', desc:'Aggregated payment statistics',
|
||
responses:[{ status:200, desc:'Success', body:{ totalRevenue:98500, totalPayments:215, byMethod:{ card:180, cash:30, eft:5 }, byEvent:[{ eventId:'ev-uuid-...', title:'Camp 2025', total:45000 }] }}]},
|
||
]},
|
||
|
||
{ title: 'Tickets', base: '/api/tickets', endpoints: [
|
||
{ method:'GET', path:'/api/tickets/mytickets', auth:'user+', desc:'Get own tickets',
|
||
responses:[{ status:200, desc:'Success', body:[{ id:'tkt-uuid-...', qrCode:'TKT-ABC123', event:{ title:'Camp 2025' }, registrationOption:{ eventOption:{ name:'Adult' }}, quantity:1, isUsed:false }]}]},
|
||
{ method:'POST', path:'/api/tickets/generate', auth:'supervisor+', desc:'Generate tickets for a registration (idempotent — skips already-generated options)',
|
||
request:{ body:{ registrationId:'reg-uuid-...' }},
|
||
responses:[{ status:201, desc:'Generated', body:{ created:2, skipped:0, tickets:[{ id:'tkt-uuid-...', qrCode:'TKT-ABC123' }] }}]},
|
||
{ method:'POST', path:'/api/tickets/scan/:qrCode', auth:'staff+', desc:'Scan and redeem a ticket',
|
||
pathParams:{ ':qrCode':'QR code string from ticket' },
|
||
request:{ body:{ quantity:1 }},
|
||
responses:[
|
||
{ status:200, desc:'Scanned', body:{ ticket:{ id:'tkt-uuid-...', qrCode:'TKT-ABC123', isUsed:true }, event:{ title:'Camp 2025' }, user:{ name:'Jane Doe' }, quantityRedeemed:1, remainingUses:0 }},
|
||
{ status:400, desc:'Already used', body:{ message:'Ticket has already been fully redeemed.' }},
|
||
{ status:404, desc:'Not found', body:{ message:'Ticket not found.' }},
|
||
]},
|
||
{ method:'GET', path:'/api/tickets/scan-preview/:qrCode', auth:'staff+', desc:'Preview a ticket scan without marking it used',
|
||
pathParams:{ ':qrCode':'QR code string' },
|
||
responses:[{ status:200, desc:'Preview', body:{ ticket:{ qrCode:'TKT-ABC123', quantity:2, isUsed:false }, event:{ title:'Camp 2025' }, user:{ name:'Jane Doe', phoneNumber:'+27821234567' }, totalRedeemed:0 }}]},
|
||
{ method:'POST', path:'/api/tickets/email', auth:'user+', desc:'Email own tickets as PDF attachments',
|
||
responses:[{ status:200, desc:'Sent', body:{ message:'Tickets emailed.', count:2 }}]},
|
||
{ method:'POST', path:'/api/tickets/send-to', auth:'staff+', desc:'Send tickets to a specific user by email or WhatsApp',
|
||
request:{ body:{ userId:'user-uuid-...', eventId:'ev-uuid-...', channel:'email' }},
|
||
responses:[{ status:200, desc:'Sent', body:{ sent:2 }}]},
|
||
{ method:'GET', path:'/api/tickets/event/:eventId', auth:'staff+', desc:'All tickets for an event',
|
||
pathParams:{ ':eventId':'Event UUID' },
|
||
responses:[{ status:200, desc:'Success', body:[{ id:'tkt-uuid-...', qrCode:'TKT-ABC123', user:{ name:'Jane Doe' }, isUsed:false, quantity:1 }]}]},
|
||
{ method:'GET', path:'/api/tickets/scans/recent', auth:'staff+', desc:'Last 50 scan events across all events',
|
||
responses:[{ status:200, desc:'Success', body:[{ ticketId:'tkt-uuid-...', scannedAt:'2025-07-10T09:05:00.000Z', quantityRedeemed:1, ticket:{ user:{ name:'Jane Doe' }, event:{ title:'Camp 2025' }}, scannedBy:{ name:'Staff Member' }}]}]},
|
||
{ method:'GET', path:'/api/tickets/scans/stats', auth:'staff+', desc:'Scan statistics grouped by event',
|
||
responses:[{ status:200, desc:'Success', body:[{ eventId:'ev-uuid-...', title:'Camp 2025', totalTickets:50, scanned:38, remaining:12 }]}]},
|
||
]},
|
||
|
||
{ title: 'Broadcasts (Email)', base: '/api/broadcasts', endpoints: [
|
||
{ method:'POST', path:'/api/broadcasts/preview', auth:'supervisor+', desc:'Dry-run a broadcast — returns resolved recipient list without sending',
|
||
request:{ body:{ userIds:['user-uuid-1','user-uuid-2'], emails:'extra@example.com\nJohn <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/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', auth:'supervisor+', desc:'Live cashup preview for an event: system income vs. reconciled actual per method, method-tagged costs, unallocated donations, net profit, and the full close/reopen history',
|
||
pathParams:{ ':eventId':'Event UUID' },
|
||
responses:[{ status:200, desc:'Success', body:{ event:{ id:'ev-uuid-...', title:'Camp 2025', cashupStatus:'open' }, paymentsByMethod:{ cash:1200, card:4500, eft:600, other:0 }, expectedCashByMethod:{ cash:1150, card:4500, eft:600, other:0 }, unallocatedDonationsTotal:150, totalCosts:900, netProfit:6450, history:[] }}]},
|
||
{ method:'PUT', path:'/api/cashups/event/:eventId/draft', auth:'admin', desc:'Save in-progress reconciliation entries (actual counted amounts, and cash denomination counts, per method) without closing the event',
|
||
pathParams:{ ':eventId':'Event UUID' },
|
||
request:{ body:{ lines:[{ method:'cash', denominations:[{ value:100, count:5 },{ value:50, count:2 }] },{ method:'card', actualAmount:4500 }] }},
|
||
responses:[{ status:200, desc:'Saved', body:{ message:'Draft saved' }}]},
|
||
{ method:'POST', path:'/api/cashups/event/:eventId/close', auth:'admin', desc:'Close the event. Pass "lines" for a full per-method cashup (variances computed against expected cash, i.e. income minus any method-tagged costs) — for the cash line, pass "denominations" and the actual amount is computed from the note/coin counts server-side. Omit "lines" for a quick close that accepts system numbers as-is. Either way, unallocated donations are counted as profit and the event is fully locked (no new registrations, payments, refunds, checkouts, or donations) until an admin reopens it. Creates a permanent EventCashup audit row.',
|
||
pathParams:{ ':eventId':'Event UUID' },
|
||
request:{ body:{ lines:[{ method:'cash', denominations:[{ value:100, count:5 },{ value:50, count:2 }], notes:'R20 short' }], notes:'Closed after evening service' }},
|
||
responses:[{ status:201, desc:'Closed', body:{ id:'cashup-uuid-...', action:'closed', unallocatedDonationsTotal:150, totalCosts:900, totalExpectedRevenue:6300, totalActualRevenue:6280 }}]},
|
||
{ method:'POST', path:'/api/cashups/event/:eventId/reopen', auth:'admin', desc:'Reopen a closed event, unlocking registrations/payments/donations again. Admin-only. Creates a permanent "reopened" EventCashup audit row.',
|
||
pathParams:{ ':eventId':'Event UUID' },
|
||
request:{ body:{ notes:'Reopening to correct a miscounted cash drawer' }},
|
||
responses:[{ status:201, desc:'Reopened', body:{ id:'cashup-uuid-...', action:'reopened' }}]},
|
||
{ method:'GET', path:'/api/cashups/audit', auth:'supervisor+', desc:'Flat audit log of every close/quick-close/reopen across events, optionally filtered by eventId and/or from/to dates',
|
||
request:{ query:{ eventId:'ev-uuid-... (optional)', from:'2026-01-01 (optional)', to:'2026-12-31 (optional)' }},
|
||
responses:[{ status:200, desc:'Success', body:[{ id:'cashup-uuid-...', action:'closed', event:{ id:'ev-uuid-...', title:'Camp 2025' }, performedBy:{ name:'Jane Supervisor' }, createdAt:'2026-07-20T18:00:00.000Z' }]}]},
|
||
]},
|
||
|
||
{ title: 'Uploads', base: '/api/uploads', endpoints: [
|
||
{ method:'POST', path:'/api/uploads/event-image', auth:'supervisor+', desc:'Upload an event cover image. Send as multipart/form-data with field name "image". Returns the public URL.',
|
||
notes:'Content-Type must be multipart/form-data. Max size is typically 5 MB.',
|
||
responses:[
|
||
{ status:200, desc:'Uploaded', body:{ url:'/uploads/event-images/camp-2025-abc123.jpg' }},
|
||
{ status:400, desc:'No file', body:{ message:'No image file provided.' }},
|
||
]},
|
||
]},
|
||
|
||
{ title: 'Sections', base: '/api/sections', endpoints: [
|
||
{ method:'GET', path:'/api/sections', auth:'staff+', desc:'List all sections with their allowed options',
|
||
responses:[{ status:200, desc:'Success', body:[{ id:'sec-uuid-...', eventId:'ev-uuid-...', name:'Adults', allowedOptions:[{ eventOptionId:'opt-uuid-...', eventOption:{ name:'Adult', price:450 }}] }]}]},
|
||
{ method:'POST', path:'/api/sections', auth:'supervisor+', desc:'Create a section for an event',
|
||
request:{ body:{ eventId:'ev-uuid-...', name:'Adults', optionIds:['opt-uuid-1','opt-uuid-2'] }},
|
||
responses:[{ status:201, desc:'Created', body:{ id:'sec-uuid-new', name:'Adults', eventId:'ev-uuid-...' }}]},
|
||
{ method:'PUT', path:'/api/sections/:id', auth:'supervisor+', desc:'Update a section',
|
||
pathParams:{ ':id':'Section UUID' },
|
||
request:{ body:{ name:'Adults & Teens', optionIds:['opt-uuid-1','opt-uuid-2','opt-uuid-3'] }},
|
||
responses:[{ status:200, desc:'Updated', body:{ id:'sec-uuid-...', name:'Adults & Teens' }}]},
|
||
{ method:'DELETE', path:'/api/sections/:id', auth:'admin', desc:'Delete a section',
|
||
pathParams:{ ':id':'Section UUID' },
|
||
responses:[{ status:200, desc:'Deleted', body:{ message:'Section deleted.' }}]},
|
||
]},
|
||
|
||
{ title: 'Banner', base: '/api/banner', endpoints: [
|
||
{ method:'GET', path:'/api/banner', auth:'public', desc:'Get the current site-wide announcement banner',
|
||
responses:[
|
||
{ status:200, desc:'Banner set', body:{ message:'Registration for Camp 2025 is now open!', type:'info', active:true }},
|
||
{ status:200, desc:'No banner', body:{ message:null, active:false }},
|
||
]},
|
||
{ method:'POST', path:'/api/banner', auth:'supervisor+', desc:'Set or update the site banner',
|
||
request:{ body:{ message:'Registration is now closed.', type:'warning', active:true }},
|
||
responses:[{ status:200, desc:'Saved', body:{ message:'Registration is now closed.', type:'warning', active:true }}]},
|
||
]},
|
||
|
||
{ title: 'Forms', base: '/api/forms', endpoints: [
|
||
{ method:'GET', path:'/api/forms/responses', auth:'staff+', desc:'List all submitted form responses across all events',
|
||
queryParams:{ eventId:'Filter by event UUID', page:'Page (default 1)', limit:'Per page (default 20)' },
|
||
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'resp-uuid-...', registrationId:'reg-uuid-...', createdAt:'2025-06-01T10:05:00.000Z', answers:[{ field:{ label:'Dietary requirements' }, value:'Vegetarian' }] }], total:1 }}]},
|
||
]},
|
||
|
||
{ title: 'Yoco Transactions', base: '/api/yoco-transactions', endpoints: [
|
||
{ method:'GET', path:'/api/yoco-transactions', auth:'supervisor+', desc:'All Yoco webhook transactions (raw events received from Yoco)',
|
||
queryParams:{ page:'Page (default 1)', limit:'Per page (default 50)', reconciled:'true|false' },
|
||
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'ytx-uuid-...', externalId:'pi_yoco_abc', amount:45000, currency:'ZAR', reconciled:true, paymentId:'pay-uuid-...', createdDate:'2025-06-01T11:00:00.000Z' }], total:1 }}]},
|
||
{ method:'GET', path:'/api/yoco-transactions/unreconciled', auth:'supervisor+', desc:'Transactions not yet matched to a payment record',
|
||
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'ytx-uuid-...', externalId:'pi_yoco_xyz', amount:45000, currency:'ZAR', reconciled:false }], total:1 }}]},
|
||
{ method:'POST', path:'/api/yoco-transactions/:id/reconcile', auth:'supervisor+', desc:'Manually link a Yoco transaction to an existing payment',
|
||
pathParams:{ ':id':'YocoTransaction UUID' },
|
||
request:{ body:{ paymentId:'pay-uuid-...' }},
|
||
responses:[{ status:200, desc:'Reconciled', body:{ id:'ytx-uuid-...', reconciled:true, paymentId:'pay-uuid-...' }}]},
|
||
{ method:'POST', path:'/api/yoco-transactions/:id/ignore', auth:'supervisor+', desc:'Mark a transaction as intentionally ignored',
|
||
pathParams:{ ':id':'YocoTransaction UUID' },
|
||
responses:[{ status:200, desc:'Ignored', body:{ id:'ytx-uuid-...', ignored:true }}]},
|
||
]},
|
||
|
||
{ title: 'WhatsApp Admin', base: '/api/whatsapp', endpoints: [
|
||
{ method:'GET', path:'/api/whatsapp/config', auth:'admin', desc:'Get the currently stored WAWP credentials from DB (tokens are partially masked)',
|
||
responses:[{ status:200, desc:'Success', body:{ instanceId:'wawp-instance-abc', accessToken:'eyJ...****', source:'db' }}]},
|
||
{ method:'POST', path:'/api/whatsapp/config', auth:'admin', desc:'Save WAWP credentials to the database (AppSetting). Takes effect immediately; no restart needed.',
|
||
request:{ body:{ accessToken:'eyJhbGciOiJSUzI1...', instanceId:'wawp-instance-abc' }},
|
||
responses:[{ status:200, desc:'Saved', body:{ message:'WhatsApp config saved.' }}]},
|
||
{ method:'GET', path:'/api/whatsapp/status', auth:'admin', desc:'Get the current WAWP session status',
|
||
responses:[
|
||
{ status:200, desc:'Connected', body:{ status:'open', phoneNumber:'+27821234567', pushName:'Hope Events' }},
|
||
{ status:200, desc:'Not connected', body:{ status:'close' }},
|
||
]},
|
||
{ method:'GET', path:'/api/whatsapp/qr', auth:'admin', desc:'Get a QR code image/string to link a WhatsApp account',
|
||
responses:[{ status:200, desc:'Success', body:{ qr:'data:image/png;base64,...', qrString:'2@abc123...' }}]},
|
||
{ method:'POST', path:'/api/whatsapp/create-instance', auth:'admin', desc:'Create a new WAWP session instance',
|
||
responses:[{ status:200, desc:'Created', body:{ instanceId:'wawp-instance-new', message:'Instance created.' }}]},
|
||
{ method:'POST', path:'/api/whatsapp/restart', auth:'admin', desc:'Restart the current WAWP instance',
|
||
responses:[{ status:200, desc:'Restarted', body:{ message:'Instance restarted.' }}]},
|
||
{ method:'POST', path:'/api/whatsapp/logout', auth:'admin', desc:'Log out the current WhatsApp session',
|
||
responses:[{ status:200, desc:'Logged out', body:{ message:'Logged out.' }}]},
|
||
]},
|
||
|
||
{ title: 'Webhooks', base: '/api/webhooks', endpoints: [
|
||
{ method:'POST', path:'/api/webhooks/yoco', auth:'HMAC', desc:'Yoco payment event webhook. Called by Yoco servers. Verified via HMAC-SHA256 signature in the X-Yoco-Signature header.',
|
||
notes:'This endpoint bypasses the global rate limiter. Raw body is parsed before JSON middleware. Refreshes early-bird pricing before computing totalDue to ensure accurate paid/partial status.',
|
||
request:{ body:{ id:'evt_yoco_abc', type:'payment.succeeded', payload:{ metadata:{ checkoutId:'checkout-id-...' }, amount:45000, currency:'ZAR' }}},
|
||
responses:[{ status:200, desc:'Acknowledged', body:{ received:true }}]},
|
||
{ method:'POST', path:'/api/webhooks/whatsapp', auth:'—', desc:'WAWP inbound message event. Called by WAWP servers when a message arrives on the linked number.',
|
||
responses:[{ status:200, desc:'Acknowledged', body:{ received:true }}]},
|
||
]},
|
||
|
||
{ title: 'Settings', base: '/api/settings', endpoints: [
|
||
{ method:'GET', path:'/api/settings', auth:'public', desc:'Public settings — org name, accent colour, logo URL, legal page slugs, registration notification email (no secrets)',
|
||
responses:[{ status:200, desc:'Success', body:{ org_name:'Hope Family Church', org_tagline:'Where everyone belongs', accent_color:'#2563eb', logo_url:'/uploads/logo.png' }}]},
|
||
{ method:'GET', path:'/api/settings/all', auth:'admin', desc:'All settings including SMTP config. smtp_pass is returned masked (••••••••); smtp_user is returned decrypted.',
|
||
responses:[{ status:200, desc:'Success', body:[{ key:'smtp_host', value:'smtp.example.com' },{ key:'smtp_user', value:'user@example.com' },{ key:'smtp_pass', value:'••••••••' }]}]},
|
||
{ method:'GET', path:'/api/settings/needs-setup', auth:'public', desc:'Returns true until the setup wizard has been completed (setup_complete flag is set).',
|
||
responses:[{ status:200, desc:'Success', body:{ needsSetup:true }}]},
|
||
{ method:'PUT', path:'/api/settings', auth:'admin', desc:'Upsert one or more settings. smtp_user and smtp_pass are AES-256-GCM encrypted before storage. Sending •••••••• for smtp_pass is a no-op.',
|
||
request:{ body:{ org_name:'Hope Family Church', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password' }},
|
||
responses:[{ status:200, desc:'Saved', body:{ message:'Settings saved.' }}]},
|
||
{ method:'POST', path:'/api/settings/test-smtp', auth:'admin (or setup token)', desc:'Test the SMTP connection with provided credentials. On success, sends a real test email to the authenticated admin and returns a friendly message. On failure, returns a human-readable message plus a raw field containing the original SMTP error for debugging. Error code 530 (Microsoft "Client not authenticated") maps to the authentication-failure message.',
|
||
request:{ body:{ host:'smtp.gmail.com', port:587, secure:false, user:'me@gmail.com', pass:'app-password', from:'me@gmail.com' }},
|
||
responses:[
|
||
{ status:200, desc:'Connection OK — test email sent', body:{ message:'SMTP connection verified — a test email has been sent to admin@example.com' }},
|
||
{ status:400, desc:'Connection failed', body:{ message:'Authentication failed — check your SMTP username and password.', raw:'535 5.7.8 Error: authentication failed' }},
|
||
]},
|
||
]},
|
||
|
||
{ title: 'Setup', base: '/api/setup', endpoints: [
|
||
{ method:'POST', path:'/api/setup/register', auth:'public (one-time)', desc:'Step 2 of the first-time setup wizard. Creates the admin account and returns a short-lived JWT. Blocked once any user exists.',
|
||
request:{ body:{ name:'Admin User', email:'admin@example.com', password:'strong-password' }},
|
||
responses:[
|
||
{ status:201, desc:'Created', body:{ token:'eyJhbGciOiJIUzI1NiJ9...', user:{ id:'uuid-...', name:'Admin User', role:'admin' }}},
|
||
{ status:400, desc:'Already exists', body:{ message:'Setup already completed — users exist.' }},
|
||
]},
|
||
{ method:'POST', path:'/api/setup', auth:'setup token or admin', desc:'Final step of the setup wizard. Saves initial site settings. Requires the JWT returned by POST /api/setup/register.',
|
||
request:{ body:{ settings:{ org_name:'Hope Family Church', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password', mail_from:'noreply@example.com' }}},
|
||
responses:[{ status:200, desc:'Setup complete', body:{ message:'Setup complete.' }}]},
|
||
]},
|
||
];
|
||
|
||
// ── Rendering helpers ─────────────────────────────────────────────────────
|
||
function hl(json) {
|
||
// Simple JSON syntax highlighting — server-side
|
||
const s = JSON.stringify(json, null, 2);
|
||
return s
|
||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, (m) => {
|
||
if (/^"/.test(m)) {
|
||
if (/:$/.test(m)) return `<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 <your-jwt>'));
|
||
if (ep.request?.body && !isMultipart) reqLines.push(headerLine('Content-Type','application/json'));
|
||
if (isMultipart) reqLines.push(headerLine('Content-Type','multipart/form-data'));
|
||
if (ep.request?.body && !isMultipart) {
|
||
reqLines.push('');
|
||
reqLines.push(hl(ep.request.body));
|
||
} else if (isMultipart) {
|
||
reqLines.push('');
|
||
reqLines.push(`<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 Hope Events!', content: 'Welcome message plus a list of upcoming events.' },
|
||
{ trigger: 'Login attempt on an account that is not yet active', channels: ['Email (if a real address is on file)', 'WhatsApp (fallback when there is no usable email)'], recipients: 'User', subject: 'Activate your Hope Events account', content: 'One-time activation link; expires after 24 hours.' },
|
||
{ trigger: 'Successful login', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'New login to your Hope Events account', content: 'Login time, approximate location, device/user agent. Security alert — always emailed regardless of the user\'s notification preference.' },
|
||
{ trigger: 'Password changed via profile update', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'Your Hope Events password was changed', content: 'Confirms the change and gives a support contact to use if it wasn\'t them.' },
|
||
{ trigger: 'Forgot-password request', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'Reset your password', content: 'Password reset link.' },
|
||
{ trigger: 'Account closed (self-service — "Deactivate" or "Delete my data")', channels: ['Email', 'WhatsApp (if preferred)'], recipients: 'User (sent to their last-known address/number just before data is wiped)', subject: 'Your Hope Events account has been closed', content: 'Confirms closure; wording differs slightly when personal data was also erased.' },
|
||
]},
|
||
{ category: 'Admin-triggered broadcasts', items: [
|
||
{ trigger: 'Bulk email to event attendees, or an ad-hoc email broadcast — sent immediately or on a schedule', channels: ['Email'], recipients: 'Selected attendees / users / ad-hoc addresses chosen by the sender', subject: 'Admin-authored', content: 'Free-form subject & body written by the sender, supporting {{name}}, {{event.title}}, {{event.link}} placeholders. Used for reminders, thank-yous, promos, and multi-step automations.' },
|
||
{ trigger: 'Bulk WhatsApp to event attendees, or an ad-hoc WhatsApp broadcast — sent immediately or on a schedule', channels: ['WhatsApp'], recipients: 'Selected attendees / users / ad-hoc phone numbers chosen by the sender', subject: '—', content: 'Free-form message written by the sender, same placeholder support as email broadcasts.' },
|
||
]},
|
||
];
|
||
|
||
function notifChannelBadges(channels) {
|
||
return channels.map(c => {
|
||
const isWa = /whatsapp/i.test(c);
|
||
return `<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 html = `<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Hope 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">Hope Events — API Reference</h1>
|
||
<a href="/" style="font-size:.82rem;color:#6b7280">← 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}) —
|
||
Click any row to expand request & 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 <jwt></code> |
|
||
Roles: <code>admin</code> > <code>supervisor</code> > <code>staff</code> > <code>user</code> |
|
||
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 → Events → edit event → Notifications step) —
|
||
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">
|
||
Hope Events API v${API_VERSION} — ${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);
|
||
app.use(errorHandler);
|
||
|
||
// Start server
|
||
app.listen(PORT, () => {
|
||
console.log(`Server is running at http://localhost:${PORT}`);
|
||
// Attempt a one-time background sync of attachment manifests into DB
|
||
try {
|
||
const { syncManifestsToDb } = require('./utils/attachmentsSync');
|
||
const prismaShared = require('./config/db');
|
||
setTimeout(async () => {
|
||
try {
|
||
const summary = await syncManifestsToDb(prismaShared, { dryRun: false });
|
||
if ((summary.imported || 0) > 0) {
|
||
console.log(`[attachments sync] Imported ${summary.imported} attachments from manifests (skipped ${summary.skipped}).`);
|
||
} else {
|
||
console.log('[attachments sync] No attachments imported (either none found or already in DB).');
|
||
}
|
||
} catch (e) {
|
||
console.warn('[attachments sync] Failed:', e?.message || e);
|
||
}
|
||
}, 1000);
|
||
} catch (e) {
|
||
console.warn('[attachments sync] Not scheduled:', e?.message || e);
|
||
}
|
||
|
||
// Schedule daily summaries at 07:00 local time (configurable)
|
||
try {
|
||
const enabled = String(process.env.DAILY_SUMMARY_ENABLED || 'true').toLowerCase() !== 'false';
|
||
if (enabled) {
|
||
const { sendDailyEventSummaries } = require('./utils/notifications');
|
||
function msUntilNext(hour, minute) {
|
||
const now = new Date();
|
||
const next = new Date(now);
|
||
next.setHours(hour, minute, 0, 0);
|
||
if (next <= now) {
|
||
next.setDate(next.getDate() + 1);
|
||
}
|
||
return next.getTime() - now.getTime();
|
||
}
|
||
async function scheduleNext() {
|
||
const delay = msUntilNext(7, 0);
|
||
setTimeout(async () => {
|
||
try {
|
||
console.log('[daily summaries] Running daily event summaries...');
|
||
await sendDailyEventSummaries(new Date());
|
||
console.log('[daily summaries] Completed sending daily event summaries.');
|
||
} catch (e) {
|
||
console.error('[daily summaries] Failed:', e?.message || e);
|
||
} finally {
|
||
// Schedule next run
|
||
scheduleNext();
|
||
}
|
||
}, delay);
|
||
}
|
||
scheduleNext();
|
||
console.log('[daily summaries] Scheduler initialized (07:00 local time). Set DAILY_SUMMARY_ENABLED=false to disable.');
|
||
} else {
|
||
console.log('[daily summaries] Scheduler disabled by env DAILY_SUMMARY_ENABLED=false');
|
||
}
|
||
} catch (e) {
|
||
console.warn('[daily summaries] Not scheduled:', e?.message || e);
|
||
}
|
||
|
||
// Daily temp-file cleanup — removes files older than 7 days from /temp
|
||
try {
|
||
const { cleanupTempFiles } = require('./utils/cleanupTemp');
|
||
function scheduleTempCleanup() {
|
||
const now = new Date();
|
||
const next = new Date(now);
|
||
next.setHours(3, 0, 0, 0); // Run at 03:00 local time (low-traffic window)
|
||
if (next <= now) next.setDate(next.getDate() + 1);
|
||
setTimeout(() => {
|
||
try {
|
||
const result = cleanupTempFiles();
|
||
if (result.deleted > 0 || result.errors > 0) {
|
||
console.log(`[temp cleanup] Deleted ${result.deleted} file(s), ${result.errors} error(s).`);
|
||
}
|
||
} catch (e) {
|
||
console.warn('[temp cleanup] Failed:', e?.message || e);
|
||
} finally {
|
||
scheduleTempCleanup();
|
||
}
|
||
}, next.getTime() - now.getTime());
|
||
}
|
||
scheduleTempCleanup();
|
||
console.log('[temp cleanup] Scheduler initialized (03:00 local time, files older than 7 days).');
|
||
} catch (e) {
|
||
console.warn('[temp cleanup] Not scheduled:', e?.message || e);
|
||
}
|
||
|
||
// Scheduled emails worker (polling)
|
||
try {
|
||
const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false';
|
||
if (enabled) {
|
||
const { getDueJobs, updateJob } = 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 {
|
||
const due = getDueJobs(new Date());
|
||
if (!due || due.length === 0) return;
|
||
for (const job of due) {
|
||
try {
|
||
updateJob(job.id, { status: 'sending', attempts: (job.attempts || 0) + 1, lastError: null });
|
||
let responseBody = null; let code = 200;
|
||
const mockRes = {
|
||
status: (c) => { code = c; return mockRes; },
|
||
json: (b) => { responseBody = b; return mockRes; }
|
||
};
|
||
if (job.broadcast && job.channel === 'whatsapp') {
|
||
const mockReq = { body: job.payload };
|
||
await sendWhatsAppBroadcast(mockReq, mockRes);
|
||
} else if (job.broadcast) {
|
||
const mockReq = { body: job.payload };
|
||
await sendBroadcast(mockReq, mockRes);
|
||
} else if (job.channel === 'whatsapp') {
|
||
const mockReq = { params: { id: job.eventId }, body: job.payload };
|
||
await whatsappEventAttendees(mockReq, mockRes);
|
||
} else {
|
||
const mockReq = { params: { id: job.eventId }, body: job.payload };
|
||
await emailEventAttendees(mockReq, mockRes);
|
||
}
|
||
if (code >= 200 && code < 300) {
|
||
updateJob(job.id, { status: 'sent', sentAt: new Date().toISOString(), lastResult: responseBody || null });
|
||
} else {
|
||
updateJob(job.id, { status: 'error', lastError: (responseBody && responseBody.message) ? responseBody.message : `HTTP ${code}` });
|
||
}
|
||
} catch (e) {
|
||
updateJob(job.id, { status: 'error', lastError: String(e?.message || e) });
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('[scheduled emails] poll failed:', e?.message || e);
|
||
}
|
||
}, isNaN(intervalMs) ? 30000 : intervalMs);
|
||
console.log('[scheduled emails] Worker initialized. Set SCHEDULED_EMAILS_ENABLED=false to disable.');
|
||
} else {
|
||
console.log('[scheduled emails] Worker disabled by env SCHEDULED_EMAILS_ENABLED=false');
|
||
}
|
||
} catch (e) {
|
||
console.warn('[scheduled emails] Worker not started:', e?.message || e);
|
||
}
|
||
});
|
||
|
||
// Handle unhandled promise rejections
|
||
process.on('unhandledRejection', (err) => {
|
||
console.log('UNHANDLED REJECTION! Shutting down...');
|
||
console.log(err.name, err.message);
|
||
process.exit(1);
|
||
});
|
||
|
||
module.exports = { app, prisma }; |