The scheduled-job store never persisted the channel field, so the send worker always fell through to its email branch regardless of what was requested. Also purges sent jobs 24h after sending instead of keeping them forever, and surfaces who each scheduled job will go to in the admin "manage scheduled" lists (now correctly filtered per channel too). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1255 lines
88 KiB
JavaScript
1255 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, purgeSentJobs } = require('./utils/scheduledEmails');
|
||
const { emailEventAttendees, whatsappEventAttendees } = require('./controllers/eventController');
|
||
const { sendBroadcast } = require('./controllers/broadcastController');
|
||
const { sendWhatsAppBroadcast } = require('./controllers/whatsappBroadcastController');
|
||
const intervalMs = parseInt(process.env.SCHEDULED_EMAILS_INTERVAL_MS || '30000', 10);
|
||
setInterval(async () => {
|
||
try {
|
||
try { purgeSentJobs(24 * 60 * 60 * 1000); } catch (e) { console.warn('[scheduled emails] purge failed:', e?.message || e); }
|
||
const due = getDueJobs(new Date());
|
||
if (!due || due.length === 0) return;
|
||
for (const job of due) {
|
||
try {
|
||
updateJob(job.id, { status: 'sending', attempts: (job.attempts || 0) + 1, lastError: null });
|
||
let responseBody = null; let code = 200;
|
||
const mockRes = {
|
||
status: (c) => { code = c; return mockRes; },
|
||
json: (b) => { responseBody = b; return mockRes; }
|
||
};
|
||
if (job.broadcast && job.channel === 'whatsapp') {
|
||
const mockReq = { body: job.payload };
|
||
await sendWhatsAppBroadcast(mockReq, mockRes);
|
||
} else if (job.broadcast) {
|
||
const mockReq = { body: job.payload };
|
||
await sendBroadcast(mockReq, mockRes);
|
||
} else if (job.channel === 'whatsapp') {
|
||
const mockReq = { params: { id: job.eventId }, body: job.payload };
|
||
await whatsappEventAttendees(mockReq, mockRes);
|
||
} else {
|
||
const mockReq = { params: { id: job.eventId }, body: job.payload };
|
||
await emailEventAttendees(mockReq, mockRes);
|
||
}
|
||
if (code >= 200 && code < 300) {
|
||
updateJob(job.id, { status: 'sent', sentAt: new Date().toISOString(), lastResult: responseBody || null });
|
||
} else {
|
||
updateJob(job.id, { status: 'error', lastError: (responseBody && responseBody.message) ? responseBody.message : `HTTP ${code}` });
|
||
}
|
||
} catch (e) {
|
||
updateJob(job.id, { status: 'error', lastError: String(e?.message || e) });
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('[scheduled emails] poll failed:', e?.message || e);
|
||
}
|
||
}, isNaN(intervalMs) ? 30000 : intervalMs);
|
||
console.log('[scheduled emails] Worker initialized. Set SCHEDULED_EMAILS_ENABLED=false to disable.');
|
||
} else {
|
||
console.log('[scheduled emails] Worker disabled by env SCHEDULED_EMAILS_ENABLED=false');
|
||
}
|
||
} catch (e) {
|
||
console.warn('[scheduled emails] Worker not started:', e?.message || e);
|
||
}
|
||
});
|
||
|
||
// Handle unhandled promise rejections
|
||
process.on('unhandledRejection', (err) => {
|
||
console.log('UNHANDLED REJECTION! Shutting down...');
|
||
console.log(err.name, err.message);
|
||
process.exit(1);
|
||
});
|
||
|
||
module.exports = { app, prisma }; |