Initial commit

Next.js + Express event management app for Hope Family Church.
This commit is contained in:
2026-07-23 15:26:47 +02:00
commit 3d381944d2
246 changed files with 57565 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
const fs = require('fs');
const path = require('path');
/**
* Checks whether the Prisma client has the EventAttachment model and the table is queryable.
* @param {import('@prisma/client').PrismaClient} prisma
*/
async function canUseEventAttachment(prisma) {
const hasModel = !!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findMany === 'function');
if (!hasModel) return { ok: false, reason: 'NO_MODEL' };
try {
await prisma.eventAttachment.findFirst({});
return { ok: true };
} catch (e) {
return { ok: false, reason: 'QUERY_ERROR', error: String(e?.message || e) };
}
}
/**
* Reads all manifest files for event attachments and inserts missing rows into DB.
* It is idempotent: it will skip entries that already exist by id. If id lookup fails,
* it tries to avoid duplicates using a composite check (eventId + filename + size).
*
* @param {import('@prisma/client').PrismaClient} prisma
* @param {{ dryRun?: boolean, removeManifestAfterImport?: boolean }} [options]
* @returns {Promise<{processedFiles:number, imported:number, skipped:number, errors:Array<{file:string,error:string}>, details:Array<{file:string, imported:number, skipped:number}>}>}
*/
async function syncManifestsToDb(prisma, options = {}) {
const { dryRun = false, removeManifestAfterImport = false } = options;
const baseDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
const result = { processedFiles: 0, imported: 0, skipped: 0, errors: [], details: [] };
const check = await canUseEventAttachment(prisma);
if (!check.ok) {
return { ...result, errors: [{ file: '*', error: `Attachments model not usable (${check.reason})${check.error ? ': ' + check.error : ''}` }] };
}
if (!fs.existsSync(baseDir)) return result;
const files = fs.readdirSync(baseDir).filter(f => f.endsWith('.attachments.json'));
for (const file of files) {
const manifestPath = path.join(baseDir, file);
result.processedFiles += 1;
let list = [];
try {
const raw = fs.readFileSync(manifestPath, 'utf-8');
list = JSON.parse(raw) || [];
} catch (e) {
result.errors.push({ file, error: 'Failed to parse JSON: ' + String(e?.message || e) });
continue;
}
let imported = 0;
let skipped = 0;
for (const entry of list) {
try {
// Check existing by id first
const existsById = entry?.id ? await prisma.eventAttachment.findUnique({ where: { id: entry.id } }) : null;
if (existsById) { skipped++; continue; }
// Check using composite heuristic to avoid duplicates
const maybeExisting = await prisma.eventAttachment.findFirst({
where: {
eventId: entry.eventId,
filename: entry.filename,
size: typeof entry.size === 'number' ? entry.size : undefined,
}
});
if (maybeExisting) { skipped++; continue; }
if (!dryRun) {
await prisma.eventAttachment.create({
data: {
id: entry.id || undefined,
eventId: entry.eventId,
originalName: entry.originalName || entry.filename || 'file',
filename: entry.filename,
mimeType: entry.mimeType || 'application/octet-stream',
size: typeof entry.size === 'number' ? entry.size : 0,
url: entry.url,
createdAt: entry.createdAt ? new Date(entry.createdAt) : undefined,
}
});
}
imported++;
} catch (e) {
result.errors.push({ file, error: 'Insert failed: ' + String(e?.message || e) });
}
}
result.imported += imported;
result.skipped += skipped;
result.details.push({ file, imported, skipped });
// Optionally remove manifest if all entries are in DB now
if (!dryRun && removeManifestAfterImport && imported > 0) {
try {
fs.unlinkSync(manifestPath);
} catch {}
}
}
return result;
}
module.exports = { syncManifestsToDb, canUseEventAttachment };
+204
View File
@@ -0,0 +1,204 @@
const prisma = require('../config/db');
const METHOD_BUCKETS = ['cash', 'card', 'eft'];
const ALL_METHODS = ['cash', 'card', 'eft', 'other'];
// Standard South African Rand note/coin denominations used for the cash count grid.
const ZAR_DENOMINATIONS = [200, 100, 50, 20, 10, 5, 2, 1, 0.5, 0.2, 0.1];
function emptyByMethod(fill = 0) {
return { cash: fill, card: fill, eft: fill, other: fill };
}
// Normalizes a free-text Payment.method into one of the fixed cashup buckets.
function bucketForMethod(method) {
const m = String(method || '').toLowerCase();
if (m.includes('cash')) return 'cash';
if (m.includes('eft')) return 'eft';
if (m.includes('card') || m.includes('yoco')) return 'card';
return 'other';
}
// Throws if the event is closed. Callers wrap this in their existing try/catch
// (res.statusCode is set before throwing, matching the rest of the controllers).
async function assertEventOpen(eventId, res) {
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, cashupStatus: true } });
if (!event) {
if (res) res.status(404);
throw new Error('Event not found');
}
if (event.cashupStatus === 'closed') {
if (res) res.status(400);
throw new Error('This event is closed. Reopen it (admin only) before making changes.');
}
return event;
}
// Same check, but resolves the event via a registrationId first (for payment/registration flows
// that receive a registrationId rather than an eventId directly).
async function assertRegistrationEventOpen(registrationId, res) {
const registration = await prisma.registration.findUnique({ where: { id: registrationId }, select: { eventId: true } });
if (!registration) {
if (res) res.status(404);
throw new Error('Registration not found');
}
await assertEventOpen(registration.eventId, res);
return registration;
}
// Core computation shared by the cashup preview/close endpoints and the Cashup/Finance/Profit reports.
//
// Two figures must never be conflated: "revenue" (gross, profit-relevant) and "expected cash"
// (net of any costs paid out of a method's float, for physically verifying a drawer/float).
// Mixing them up would double-subtract method-tagged costs from profit.
async function computeEventFinancials(eventId) {
const event = await prisma.event.findUnique({
where: { id: eventId },
select: { id: true, title: true, cashupStatus: true, cashupDraft: true, closedAt: true, closedById: true, reopenedAt: true, reopenedById: true }
});
if (!event) {
throw new Error('Event not found');
}
const [payments, costs, tickets, salesRows, history] = await Promise.all([
prisma.payment.findMany({
where: { OR: [{ eventId }, { registration: { eventId } }] }
}),
prisma.eventCost.findMany({ where: { eventId }, include: { eventOption: { select: { id: true, name: true } } } }),
prisma.ticket.findMany({
where: { eventId },
include: { registrationOption: { select: { eventOptionId: true } } }
}),
prisma.registrationOption.findMany({
where: { registration: { eventId, status: 'paid' } },
include: { eventOption: { select: { id: true, name: true, price: true } } }
}),
prisma.eventCashup.findMany({
where: { eventId },
include: {
lines: { include: { denominations: true } },
performedBy: { select: { id: true, name: true, email: true } }
},
orderBy: { createdAt: 'desc' }
})
]);
// Ticket quantity sold per EventOption (used to price per-item costs)
const quantityByOption = {};
for (const t of tickets) {
const optId = t.registrationOption?.eventOptionId;
if (!optId) continue;
quantityByOption[optId] = (quantityByOption[optId] || 0) + t.quantity;
}
const nonRefundPayments = payments.filter(p => p.amount > 0);
const unallocatedDonations = payments.filter(p => p.isDonation && !p.registrationId);
const unallocatedDonationsTotal = unallocatedDonations.reduce((sum, p) => sum + p.amount, 0);
const totalDonations = payments.filter(p => p.isDonation).reduce((sum, p) => sum + p.amount, 0);
const paymentsByMethod = emptyByMethod();
for (const p of nonRefundPayments) {
paymentsByMethod[bucketForMethod(p.method)] += p.amount;
}
const totalRevenue = payments.reduce((sum, p) => sum + p.amount, 0);
// Costs, with computed totals and attribution to a payment method's float (if tagged)
const costBreakdown = costs.map(c => {
const total = c.costType === 'per_item'
? c.amount * (quantityByOption[c.eventOptionId] || 0)
: c.amount;
return { ...c, total };
});
const costsByMethod = emptyByMethod();
let untaggedCostsTotal = 0;
for (const c of costBreakdown) {
if (c.paidFromMethod && ALL_METHODS.includes(c.paidFromMethod)) {
costsByMethod[c.paidFromMethod] += c.total;
} else {
untaggedCostsTotal += c.total;
}
}
const totalCosts = untaggedCostsTotal + ALL_METHODS.reduce((s, m) => s + costsByMethod[m], 0);
// What should physically be on hand per method, after known payouts from that float
const expectedCashByMethod = emptyByMethod();
for (const m of ALL_METHODS) expectedCashByMethod[m] = paymentsByMethod[m] - costsByMethod[m];
// Most recent reconciliation on record (if any) — the source of "actual" truth
const latestReconciled = history.find(h => h.action === 'closed' || h.action === 'quick_closed') || null;
const reconciled = latestReconciled ? {
id: latestReconciled.id,
action: latestReconciled.action,
createdAt: latestReconciled.createdAt,
performedBy: latestReconciled.performedBy,
notes: latestReconciled.notes,
byMethod: (() => {
const out = {};
for (const m of ALL_METHODS) {
const line = latestReconciled.lines.find(l => l.method === m);
out[m] = {
expected: line ? line.expectedAmount : expectedCashByMethod[m],
actual: line && line.actualAmount != null ? line.actualAmount : null,
variance: line && line.variance != null ? line.variance : null,
notes: line ? line.notes : null,
denominations: line ? line.denominations : []
};
}
return out;
})()
} : null;
// Reconciled value is truth; system (expected) value is the fallback when nothing was counted.
// Tagged costs are added back so a reconciled cash count still yields a correct *gross* income figure —
// costs get subtracted from profit exactly once, via totalCosts, never twice.
const effectiveGrossIncomeByMethod = emptyByMethod();
for (const m of ALL_METHODS) {
const actual = reconciled?.byMethod?.[m]?.actual;
const base = actual != null ? actual : expectedCashByMethod[m];
effectiveGrossIncomeByMethod[m] = base + costsByMethod[m];
}
const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0);
const netProfit = effectiveTotalRevenue - totalCosts;
// What was actually sold, by ticket type — for the Finance report's income-stream breakdown
const salesByOptionMap = {};
for (const ro of salesRows) {
const opt = ro.eventOption;
if (!opt) continue;
if (!salesByOptionMap[opt.id]) salesByOptionMap[opt.id] = { eventOptionId: opt.id, name: opt.name, quantitySold: 0, revenue: 0 };
const unitPrice = ro.priceSnapshot != null ? ro.priceSnapshot : opt.price;
salesByOptionMap[opt.id].quantitySold += ro.quantity;
salesByOptionMap[opt.id].revenue += unitPrice * ro.quantity;
}
const salesByOption = Object.values(salesByOptionMap);
return {
event,
paymentsByMethod,
totalRevenue,
costs: costBreakdown,
costsByMethod,
untaggedCostsTotal,
totalCosts,
expectedCashByMethod,
reconciled,
effectiveGrossIncomeByMethod,
effectiveTotalRevenue,
netProfit,
unallocatedDonations,
unallocatedDonationsTotal,
totalDonations,
salesByOption,
history
};
}
module.exports = {
METHOD_BUCKETS,
ALL_METHODS,
ZAR_DENOMINATIONS,
bucketForMethod,
assertEventOpen,
assertRegistrationEventOpen,
computeEventFinancials
};
+39
View File
@@ -0,0 +1,39 @@
const fs = require('fs');
const path = require('path');
const TEMP_DIR = path.join(__dirname, '..', '..', 'temp');
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
/**
* Deletes files in the temp directory that are older than 7 days.
* Returns a summary: { deleted, errors }.
*/
function cleanupTempFiles() {
try {
if (!fs.existsSync(TEMP_DIR)) return { deleted: 0, errors: 0 };
const files = fs.readdirSync(TEMP_DIR);
const cutoff = Date.now() - MAX_AGE_MS;
let deleted = 0;
let errors = 0;
for (const file of files) {
try {
const filePath = path.join(TEMP_DIR, file);
const stat = fs.statSync(filePath);
if (stat.isFile() && stat.mtimeMs < cutoff) {
fs.unlinkSync(filePath);
deleted++;
}
} catch {
errors++;
}
}
return { deleted, errors };
} catch {
return { deleted: 0, errors: 1 };
}
}
module.exports = { cleanupTempFiles };
+389
View File
@@ -0,0 +1,389 @@
const nodemailer = require('nodemailer');
// ─── Transport ────────────────────────────────────────────────────────────────
// The SMTP transporter is built lazily and rebuilt whenever the settings cache
// reports a different configuration (e.g. after an admin updates SMTP settings).
const { getSettingSync } = require('./settingsCache');
/** Read current SMTP config from settings cache, falling back to env vars. */
function _smtpConfig() {
return {
host: getSettingSync('smtp_host', process.env.SMTP_HOST || process.env.EMAIL_HOST || ''),
port: getSettingSync('smtp_port', process.env.SMTP_PORT || process.env.EMAIL_PORT || '587'),
secure: getSettingSync('smtp_secure', process.env.SMTP_SECURE || process.env.EMAIL_SECURE || 'false'),
user: getSettingSync('smtp_user', process.env.SMTP_USER || process.env.EMAIL_USER || ''),
pass: getSettingSync('smtp_pass', process.env.SMTP_PASS || process.env.EMAIL_PASS || ''),
from: getSettingSync('smtp_from', process.env.MAIL_FROM || process.env.EMAIL_FROM || ''),
};
}
function _configHash(c) {
return [c.host, c.port, c.secure, c.user, c.pass].join('|');
}
let _cachedTransporter = null;
let _cachedConfigHash = null;
function _getTransporter() {
const cfg = _smtpConfig();
const hash = _configHash(cfg);
if (!_cachedTransporter || hash !== _cachedConfigHash) {
_cachedConfigHash = hash;
if (cfg.host) {
_cachedTransporter = nodemailer.createTransport({
host: cfg.host,
port: parseInt(cfg.port, 10) || 587,
secure: String(cfg.secure).toLowerCase() === 'true' || String(cfg.port) === '465',
auth: cfg.user && cfg.pass ? { user: cfg.user, pass: cfg.pass } : undefined,
});
} else {
_cachedTransporter = nodemailer.createTransport({ jsonTransport: true });
if (process.env.NODE_ENV !== 'production') {
console.info('[email] SMTP not configured — using jsonTransport (dev). Configure SMTP via Admin → Site Settings or set SMTP_HOST env var.');
}
}
}
return { transporter: _cachedTransporter, cfg };
}
async function sendMail({ to, subject, html, text, attachments }) {
// Never send to anonymised/deleted accounts
if (!to || String(to).endsWith('@deleted.invalid')) {
console.info('[email] Skipping send to deleted account:', to);
return;
}
const { transporter, cfg } = _getTransporter();
const from = cfg.from || 'no-reply@hope-events.local';
const info = await transporter.sendMail({ from, to, subject, html, text, ...(attachments ? { attachments } : {}) });
if (transporter.options && transporter.options.jsonTransport) {
try {
const payload = typeof info.message === 'string' ? JSON.parse(info.message) : info.message;
console.info('[email][dev] simulated:', { to, subject, envelope: info.envelope });
if (payload && payload.html) console.info('[email][dev] html (first 300 chars):', String(payload.html).slice(0, 300));
} catch {
console.info('[email][dev] simulated (raw):', info && info.message);
}
}
}
// ─── Shared template helpers ──────────────────────────────────────────────────
function getOrg() {
const urlFallback = process.env.APP_BASE_URL || process.env.FRONTEND_URL || 'http://localhost:3001';
return {
name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'),
tagline: getSettingSync('org_tagline', process.env.ORG_TAGLINE || 'Connecting community through events'),
email: getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || ''),
url: getSettingSync('app_base_url', urlFallback).replace(/\/$/, ''),
headerColor: getSettingSync('accent_color', process.env.EMAIL_HEADER_COLOR || '#1e3a5f'),
};
}
/**
* Wraps HTML content in a professional, responsive email shell.
* @param {string} body - Inner HTML content
* @param {{ preheader?: string }} options
*/
function emailWrapper(body, { preheader = '' } = {}) {
const org = getOrg();
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<title>${org.name}</title>
</head>
<body style="margin:0;padding:0;background-color:#f1f5f9;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%">
${preheader ? `<div style="display:none;font-size:1px;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;mso-hide:all">${preheader}&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;</div>` : ''}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f1f5f9">
<tr><td align="center" style="padding:40px 16px">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px">
<!-- Header -->
<tr><td style="background:linear-gradient(135deg,${org.headerColor} 0%,#2d5287 100%);border-radius:12px 12px 0 0;padding:40px 48px;text-align:center">
<h1 style="color:#ffffff;font-size:28px;font-weight:800;margin:0;letter-spacing:-0.5px;font-family:${ff};text-shadow:0 1px 3px rgba(0,0,0,0.3)">${org.name}</h1>
<p style="color:#ffffff;font-size:14px;font-weight:500;margin:8px 0 0 0;font-family:${ff};letter-spacing:0.3px;opacity:0.9">${org.tagline}</p>
</td></tr>
<!-- Body -->
<tr><td style="background:#ffffff;border-left:1px solid #e2e8f0;border-right:1px solid #e2e8f0;padding:48px 48px 40px 48px">
<div style="font-family:${ff};color:#1e293b;font-size:15px;line-height:1.75">
${body}
</div>
</td></tr>
<!-- Footer -->
<tr><td style="background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px;padding:24px 48px;text-align:center">
<p style="color:#94a3b8;font-size:12px;margin:0 0 4px 0;font-family:${ff}">
${org.name} &bull;
<a href="mailto:${org.email}" style="color:#94a3b8;text-decoration:underline">${org.email}</a> &bull;
<a href="${org.url}" style="color:#94a3b8;text-decoration:underline">${org.url}</a>
</p>
<p style="color:#cbd5e1;font-size:11px;margin:6px 0 0 0;font-family:${ff}">
This email was sent because you have an account or registration with ${org.name}.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
/** Renders a prominent CTA button. */
function ctaButton(label, url, { bg = '#2563eb', fg = '#ffffff' } = {}) {
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:28px auto 8px auto">
<tr><td align="center" style="border-radius:8px;background-color:${bg};mso-padding-alt:0px">
<a href="${url}" target="_blank"
style="display:inline-block;padding:14px 36px;font-size:15px;font-weight:700;color:${fg};text-decoration:none;border-radius:8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;letter-spacing:0.2px;mso-hide:none"
>${label}</a>
</td></tr>
</table>`;
}
/** Renders a fallback link below a CTA button. */
function fallbackLink(url) {
return `<p style="text-align:center;margin:4px 0 0 0;font-size:12px;color:#94a3b8;word-break:break-all">
Or copy this link: <a href="${url}" style="color:#2563eb">${url}</a>
</p>`;
}
/** Horizontal rule. */
function divider() {
return `<div style="border-top:1px solid #f1f5f9;margin:32px 0"></div>`;
}
/** Coloured callout box. type: info | success | warning | danger | neutral */
function callout(content, type = 'info') {
const map = {
info: { bg: '#eff6ff', border: '#3b82f6', color: '#1e40af' },
success: { bg: '#f0fdf4', border: '#22c55e', color: '#166534' },
warning: { bg: '#fffbeb', border: '#f59e0b', color: '#92400e' },
danger: { bg: '#fef2f2', border: '#ef4444', color: '#991b1b' },
neutral: { bg: '#f8fafc', border: '#e2e8f0', color: '#475569' },
};
const s = map[type] || map.info;
return `<div style="background:${s.bg};border-left:4px solid ${s.border};border-radius:0 6px 6px 0;padding:16px 20px;margin:24px 0;color:${s.color};font-size:14px;line-height:1.6">
${content}
</div>`;
}
/** Numbered payment option row. */
function paymentOption(num, title, detail) {
return `<tr>
<td style="padding:14px 16px 14px 0;vertical-align:top;width:28px">
<div style="width:26px;height:26px;border-radius:50%;background:#2563eb;color:#fff;font-size:13px;font-weight:700;text-align:center;line-height:26px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${num}</div>
</td>
<td style="padding:14px 0;border-bottom:1px solid #f1f5f9;vertical-align:top">
<p style="margin:0 0 4px 0;font-size:14px;font-weight:700;color:#1e293b;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${title}</p>
<div style="font-size:13px;color:#64748b;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;line-height:1.5">${detail}</div>
</td>
</tr>`;
}
// ─── Builder functions ────────────────────────────────────────────────────────
function buildPasswordResetEmail({ name, resetUrl }) {
const org = getOrg();
const preheader = `Reset your ${org.name} password. This link expires in 1 hour.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Password reset request</p>
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">We received a request to reset your password.</p>
<p style="margin:0 0 8px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151">Click the button below to choose a new password. This link will expire in <strong>1 hour</strong>.</p>
${ctaButton('Reset my password', resetUrl)}
${fallbackLink(resetUrl)}
${divider()}
<p style="margin:0;font-size:13px;color:#94a3b8">
If you did not request a password reset, you can safely ignore this email — your password will not be changed.
If you're concerned, contact us at <a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.
</p>`;
const text = `Hi ${name || 'there'},\n\nWe received a request to reset your ${org.name} password.\n\nReset link (expires in 1 hour):\n${resetUrl}\n\nIf you did not request this, ignore this email.\n\n${org.name}${org.email}`;
return { text, html: emailWrapper(body, { preheader }) };
}
function buildPasswordChangedEmail({ name, when, supportEmail }) {
const org = getOrg();
const contact = supportEmail || org.email;
const whenText = when ? new Date(when).toLocaleString() : 'recently';
const preheader = `Your ${org.name} password was changed. If this wasn't you, act immediately.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Password changed</p>
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">Security notification for your account</p>
<p style="margin:0 0 8px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151">
Your <strong>${org.name}</strong> account password was successfully changed
${when ? `on <strong>${whenText}</strong>` : 'recently'}.
</p>
${callout(`<strong>This wasn't you?</strong><br/>
If you did not make this change, your account may be compromised. Contact us immediately at
<a href="mailto:${contact}" style="color:#991b1b;font-weight:600">${contact}</a>
and reset your password right away.`, 'danger')}
<p style="margin:24px 0 0 0;font-size:13px;color:#94a3b8">
If you made this change, no further action is needed. This is an automated security notification.
</p>`;
const text = `Hi ${name || 'there'},\n\nYour ${org.name} password was changed ${when ? 'on ' + whenText : 'recently'}.\n\nIf you did NOT make this change, contact us immediately at ${contact}.\n\n${org.name}${org.email}`;
return { text, html: emailWrapper(body, { preheader }) };
}
function buildLoginNotificationEmail({ name, when, location, userAgent }) {
const org = getOrg();
const preheader = `New login to your ${org.name} account detected.`;
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">New login detected</p>
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">A new session was opened on your account</p>
<p style="margin:0 0 24px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 24px 0">
<tr style="background:#f8fafc">
<td style="padding:12px 16px;font-size:12px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;width:36%;font-family:${ff}">Detail</td>
<td style="padding:12px 16px;font-size:12px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Value</td>
</tr>
<tr>
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#64748b;font-size:13px;font-weight:600;font-family:${ff}">Time</td>
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#1e293b;font-size:14px;font-weight:500;font-family:${ff}">${when}</td>
</tr>
<tr>
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#64748b;font-size:13px;font-weight:600;font-family:${ff}">Location</td>
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#1e293b;font-size:14px;font-weight:500;font-family:${ff}">${location}</td>
</tr>
<tr>
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#64748b;font-size:13px;font-weight:600;font-family:${ff}">Device</td>
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#1e293b;font-size:14px;font-weight:500;word-break:break-all;font-family:${ff}">${userAgent}</td>
</tr>
</table>
${callout(`<strong>Not you?</strong> If you don't recognise this login, change your password immediately and contact us at <a href="mailto:${org.email}" style="color:#991b1b">${org.email}</a>.`, 'danger')}
<p style="margin:24px 0 0 0;font-size:13px;color:#94a3b8">If this was you, no action is needed.</p>`;
const text = `Hi ${name || 'there'},\n\nA new login to your ${org.name} account was detected.\n\nTime: ${when}\nLocation: ${location}\nDevice: ${userAgent}\n\nIf this was NOT you, change your password immediately and contact ${org.email}.\n\n${org.name}`;
return { text, html: emailWrapper(body, { preheader }) };
}
function buildWelcomeEmail({ name, events }) {
const org = getOrg();
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
const hasEvents = Array.isArray(events) && events.length > 0;
const preheader = `Welcome to ${org.name}! Your account is ready.`;
const eventsBlock = hasEvents
? `${divider()}
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 16px 0">Upcoming events</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
${events.map(e => `<tr>
<td style="padding:12px 0;border-bottom:1px solid #f1f5f9">
<p style="margin:0 0 2px 0;font-size:14px;font-weight:700;color:#1e293b;font-family:${ff}">${e.title}</p>
<p style="margin:0;font-size:13px;color:#64748b;font-family:${ff}">${new Date(e.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}</p>
</td>
</tr>`).join('')}
</table>
${ctaButton('Browse all events', org.url + '/events')}`
: `<p style="color:#64748b;font-size:14px;margin:0">Keep an eye on our website — new events are added regularly!</p>
${ctaButton('View events', org.url + '/events')}`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Welcome to ${org.name}!</p>
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">Your account has been created</p>
<p style="margin:0 0 16px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
<p style="margin:0 0 0 0;color:#374151">
Your ${org.name} account is set up and ready to go. Use your account to register for events, manage your bookings, and view your tickets — all in one place.
</p>
${eventsBlock}
${divider()}
<p style="margin:0;font-size:13px;color:#94a3b8">We look forward to seeing you at our events!</p>`;
const eventsText = hasEvents
? `Upcoming events:\n${events.map(e => `- ${e.title} (${new Date(e.startDate).toLocaleDateString()})`).join('\n')}`
: 'Keep an eye on our website for upcoming events.';
const text = `Welcome to ${org.name}, ${name || 'there'}!\n\nYour account has been created successfully.\n\n${eventsText}\n\n${org.url}\n\n${org.name}${org.email}`;
return { text, html: emailWrapper(body, { preheader }) };
}
function buildAccountActivationEmail({ name, activationUrl }) {
const org = getOrg();
const preheader = `Activate your ${org.name} account to get started.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Activate your account</p>
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">One step to access your events and tickets</p>
<p style="margin:0 0 8px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151">
Your ${org.name} account is ready, but needs to be activated. Click below to set your password and access your registrations, tickets, and more.
</p>
${ctaButton('Activate my account', activationUrl, { bg: '#059669' })}
${fallbackLink(activationUrl)}
${callout('This activation link expires in <strong>24 hours</strong>.', 'warning')}
<p style="margin:24px 0 0 0;font-size:13px;color:#94a3b8">
If you didn't expect this email, you can safely ignore it.
</p>`;
const text = `Hi ${name || 'there'},\n\nActivate your ${org.name} account by visiting the link below:\n\n${activationUrl}\n\nThis link expires in 24 hours.\n\nIf you didn't expect this, ignore this email.\n\n${org.name}${org.email}`;
return { text, html: emailWrapper(body, { preheader }) };
}
function buildAccountClosedEmail({ name, dataDeleted }) {
const org = getOrg();
const preheader = `Your ${org.name} account has been closed.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Account closed</p>
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">Confirmation of account closure</p>
<p style="margin:0 0 8px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
<p style="margin:0 0 16px 0;color:#374151">
This email confirms that your <strong>${org.name}</strong> account has been successfully closed.
</p>
${dataDeleted
? `<p style="margin:0 0 16px 0;color:#374151">All personal data associated with your account has been permanently deleted as requested.</p>`
: `<p style="margin:0 0 16px 0;color:#374151">Your account has been deactivated. If you would also like your personal data permanently deleted, please contact us at <a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.</p>`
}
<p style="margin:0 0 0 0;color:#374151">
If you did not request this, please contact us immediately at
<a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.
</p>`;
const dataNote = dataDeleted
? 'All personal data has been permanently deleted.'
: 'Your account has been deactivated. Contact us if you also want your data deleted.';
const text = `Hi ${name || 'there'},\n\nYour ${org.name} account has been closed.\n\n${dataNote}\n\nIf you did not request this, contact us immediately at ${org.email}.\n\n${org.name}`;
return { text, html: emailWrapper(body, { preheader }) };
}
module.exports = {
sendMail,
emailWrapper,
ctaButton,
fallbackLink,
divider,
callout,
paymentOption,
buildPasswordResetEmail,
buildPasswordChangedEmail,
buildLoginNotificationEmail,
buildWelcomeEmail,
buildAccountActivationEmail,
buildAccountClosedEmail,
};
+79
View File
@@ -0,0 +1,79 @@
/**
* AES-256-GCM encryption helpers for sensitive settings stored in the database.
*
* Encrypted values are stored with an "enc:" prefix so they can be detected
* and decrypted transparently when read back from the cache.
*
* Key is derived once at startup from JWT_SECRET via scrypt so it never
* appears in plain text in the codebase.
*/
const crypto = require('crypto');
const SENTINEL = 'enc:';
// Derive a stable 32-byte key from JWT_SECRET using scrypt.
// The fixed salt is fine here — its purpose is to namespace this key
// so it can't be confused with keys derived for other purposes.
const ENCRYPTION_KEY = (() => {
const secret = process.env.JWT_SECRET || 'default-unsafe-key-CHANGE-IN-PRODUCTION';
if (secret === 'default-unsafe-key-CHANGE-IN-PRODUCTION') {
console.warn('[encryption] WARNING: JWT_SECRET is not set. Sensitive settings will be encrypted with an insecure fallback key. Set JWT_SECRET in production.');
}
return crypto.scryptSync(secret, 'hope-events-settings-aes256gcm-v1', 32);
})();
/**
* Encrypt a plaintext string. Returns an "enc:<iv>:<authTag>:<ciphertext>" string.
* Returns the original value if it is already encrypted or falsy.
* @param {string} plaintext
* @returns {string}
*/
function encrypt(plaintext) {
if (!plaintext) return plaintext;
if (typeof plaintext !== 'string') plaintext = String(plaintext);
if (plaintext.startsWith(SENTINEL)) return plaintext; // already encrypted
const iv = crypto.randomBytes(12); // 96-bit IV for GCM
const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `${SENTINEL}${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`;
}
/**
* Decrypt a value produced by encrypt(). Returns the plaintext.
* Returns the original value unchanged if it does not start with "enc:".
* @param {string} value
* @returns {string}
*/
function decrypt(value) {
if (!value || typeof value !== 'string') return value;
if (!value.startsWith(SENTINEL)) return value; // not encrypted
try {
const inner = value.slice(SENTINEL.length);
const parts = inner.split(':');
if (parts.length !== 3) throw new Error('Invalid encrypted value format');
const [ivHex, tagHex, cipherHex] = parts;
const iv = Buffer.from(ivHex, 'hex');
const tag = Buffer.from(tagHex, 'hex');
const ciphertext = Buffer.from(cipherHex,'hex');
const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return decrypted.toString('utf8');
} catch (err) {
console.error('[encryption] Failed to decrypt value:', err.message);
return ''; // return empty rather than crashing
}
}
/**
* Returns true if the value looks like it was produced by encrypt().
* @param {string} value
* @returns {boolean}
*/
function isEncrypted(value) {
return typeof value === 'string' && value.startsWith(SENTINEL);
}
module.exports = { encrypt, decrypt, isEncrypted };
+43
View File
@@ -0,0 +1,43 @@
// Prisma error codes all match P followed by 4 digits
const PRISMA_CODE_RE = /^P\d{4}$/;
/**
* Returns a client-safe error message.
* Prisma errors and connection errors can expose table names, column names,
* DB hostnames and ports — replace those with generic messages.
* Intentional application errors (thrown with `new Error('...')` in controllers)
* are passed through unchanged.
*/
function safeErrorMessage(err) {
if (!err) return 'An unexpected error occurred.';
const code = String(err.code || '');
// Prisma client / query engine errors
if (PRISMA_CODE_RE.test(code)) {
switch (code) {
case 'P2002': return 'A record with that value already exists.';
case 'P2025': return 'Record not found.';
case 'P2003': return 'Operation failed due to a related record constraint.';
case 'P2016': return 'Required record not found.';
default: return 'A database error occurred. Please try again.';
}
}
const msg = String(err.message || '');
// DB connection / network errors leak hostnames and ports
if (
msg.includes("Can't reach database") ||
msg.includes('ECONNREFUSED') ||
msg.includes('ETIMEDOUT') ||
msg.includes('Connection refused') ||
msg.includes('database server')
) {
return 'A database connection error occurred. Please try again later.';
}
return msg || 'An unexpected error occurred.';
}
module.exports = { safeErrorMessage };
+984
View File
@@ -0,0 +1,984 @@
const prisma = require('../config/db');
const { sendMail, emailWrapper, ctaButton, fallbackLink, divider, callout, paymentOption } = require('./email');
const { computeRegistrationTotalDue } = require('./pricing');
// ─── Formatting helpers ───────────────────────────────────────────────────────
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
function fmtAmount(amt) {
const n = Number(amt || 0);
return `R${n.toFixed(2)}`;
}
function fmtDate(d) {
try { return new Date(d).toLocaleString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }); } catch { return String(d); }
}
function fmtDateShort(d) {
try { return new Date(d).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); } catch { return String(d); }
}
const { getSettingSync } = require('./settingsCache');
function getOrg() {
return {
name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'),
email: process.env.EMAIL_FROM || process.env.EMAIL_USER || '',
url: (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''),
};
}
function getRegistrationsInbox() {
// Supports comma-separated list; return the first address for single-recipient use
const raw = getSettingSync('reg_notification_emails', process.env.REGISTRATIONS_EMAIL || '');
return raw.split(',')[0].trim() || '';
}
function getRegistrationsInboxAll() {
// Returns the full comma-separated string for multi-recipient sends
return getSettingSync('reg_notification_emails', process.env.REGISTRATIONS_EMAIL || '');
}
/**
* Resolves who should receive registration/payment/daily-summary notices for an event:
* the event's configured `notifyRecipients`, or the event creator when none are set.
*/
function getEventNotifyEmails(event) {
const recipients = Array.isArray(event?.notifyRecipients) ? event.notifyRecipients : [];
const emails = recipients.map(u => u?.email).filter(Boolean);
if (emails.length) return emails;
return event?.createdBy?.email ? [event.createdBy.email] : [];
}
/**
* Builds the deduplicated admin "to" list for an event notification: the global
* registrations inbox plus that event's notify recipients (or its creator as fallback).
*/
function buildEventNotifyRecipientList(event) {
const inbox = getRegistrationsInbox();
const seen = new Set();
const to = [];
if (inbox) { seen.add(inbox); to.push(inbox); }
for (const email of getEventNotifyEmails(event)) {
if (!seen.has(email)) { seen.add(email); to.push(email); }
}
return to;
}
// ─── Data loaders ─────────────────────────────────────────────────────────────
async function loadRegistrationFull(registrationId) {
return prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
payments: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
},
});
}
async function loadPaymentFull(paymentId) {
return prisma.payment.findUnique({
where: { id: paymentId },
include: {
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
registration: {
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
payments: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
},
},
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
},
});
}
// ─── Shared building blocks ───────────────────────────────────────────────────
/** Renders the selections summary table. */
function selectionsTable(registrationOptions) {
const rows = (registrationOptions || []).map(ro => {
const name = ro.eventOption?.name || 'Option';
const qty = ro.quantity || 1;
const price = ro.eventOption?.price || 0;
return `<tr>
<td style="padding:10px 16px 10px 0;font-size:14px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${name}</td>
<td style="padding:10px 0;font-size:14px;color:#374151;text-align:center;font-family:${ff};border-bottom:1px solid #f1f5f9">×${qty}</td>
<td style="padding:10px 0 10px 16px;font-size:14px;color:#374151;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount(price * qty)}</td>
</tr>`;
});
if (!rows.length) return `<p style="color:#94a3b8;font-size:14px;margin:0">No items</p>`;
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 4px 0">
<tr style="background:#f8fafc">
<th style="padding:10px 16px 10px 0;font-size:12px;font-weight:700;color:#64748b;text-align:left;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Item</th>
<th style="padding:10px 0;font-size:12px;font-weight:700;color:#64748b;text-align:center;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Qty</th>
<th style="padding:10px 0 10px 16px;font-size:12px;font-weight:700;color:#64748b;text-align:right;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Amount</th>
</tr>
${rows.join('')}
</table>`;
}
/** Renders a financial summary line. */
function financialSummary(totalDue, totalPaid, balance) {
const ff2 = ff;
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:16px 0 0 0">
<tr>
<td style="padding:6px 0;font-size:13px;color:#64748b;font-family:${ff2}">Total due</td>
<td style="padding:6px 0;font-size:13px;color:#374151;text-align:right;font-weight:500;font-family:${ff2}">${fmtAmount(totalDue)}</td>
</tr>
<tr>
<td style="padding:6px 0;font-size:13px;color:#64748b;font-family:${ff2}">Amount paid</td>
<td style="padding:6px 0;font-size:13px;color:#374151;text-align:right;font-weight:500;font-family:${ff2}">${fmtAmount(totalPaid)}</td>
</tr>
<tr style="border-top:2px solid #e2e8f0">
<td style="padding:10px 0 6px 0;font-size:15px;font-weight:800;color:#${balance <= 0 ? '059669' : '1e293b'};font-family:${ff2}">${balance <= 0 ? 'Fully paid' : 'Balance due'}</td>
<td style="padding:10px 0 6px 0;font-size:15px;font-weight:800;color:#${balance <= 0 ? '059669' : '1e293b'};text-align:right;font-family:${ff2}">${fmtAmount(balance)}</td>
</tr>
</table>`;
}
/**
* Renders the account CTA section at the bottom of user-facing registration emails.
* isActive: true → Login prompt; false → Create account prompt.
*/
function accountCta(isActive, siteUrl) {
if (isActive) {
return `${divider()}
<p style="font-size:14px;font-weight:700;color:#0f172a;margin:0 0 6px 0;font-family:${ff}">Manage your registration online</p>
<p style="font-size:13px;color:#64748b;margin:0 0 16px 0;font-family:${ff}">
Log in to your account to view your registration, make payments, and download your tickets.
</p>
${ctaButton('Log in to your account', siteUrl + '/login', { bg: '#0f172a' })}`;
}
return `${divider()}
<p style="font-size:14px;font-weight:700;color:#0f172a;margin:0 0 6px 0;font-family:${ff}">Create your account</p>
<p style="font-size:13px;color:#64748b;margin:0 0 16px 0;font-family:${ff}">
Create a free account to manage your registrations, make payments online, and access your tickets — all in one place.
</p>
${ctaButton('Create your account', siteUrl + '/register', { bg: '#0f172a' })}`;
}
/**
* Renders the payment options section for registration emails.
* @param {{ balance, yocoLink, source, siteUrl, formRequired }}
* source: 'admin' (manual/self-service/at-door) | 'user' (self-register via website)
*/
function paymentSection({ balance, yocoLink, source, siteUrl, formRequired, isUserActive }) {
if (balance <= 0 && !formRequired) {
return callout(
`<strong style="font-size:15px">🎟️ You\'re all set!</strong><br/>
<span style="font-size:13px">No payment required. Your tickets have been sent in a separate email.</span>`,
'success'
);
}
if (balance <= 0 && formRequired) {
return callout(
`<strong>One more step — attendee form required</strong><br/>
<span style="font-size:13px">This event requires an attendee information form before tickets can be issued. The form was presented during registration. If you haven't submitted it yet, please contact us.</span>`,
'warning'
);
}
// Balance due — build payment options
const isAdmin = source === 'admin';
let optNum = 1;
const options = [];
if (isAdmin && yocoLink) {
options.push(paymentOption(
optNum++,
'Pay online now (quickest)',
`<a href="${yocoLink}" style="color:#2563eb;font-weight:600;text-decoration:underline">${yocoLink}</a><br/>
<span style="color:#94a3b8;font-style:italic">Already paid? You can safely ignore this option.</span>`
));
}
options.push(paymentOption(
optNum++,
`Pay via our website`,
`Visit <a href="${siteUrl}" style="color:#2563eb">${siteUrl}</a> to pay online.${
isUserActive === false
? `<br/><span style="color:#64748b">You'll need to create a free account to pay online.</span>`
: isUserActive === true
? `<br/><span style="color:#64748b">Log in to access your registration and pay.</span>`
: ''
}`
));
options.push(paymentOption(
optNum++,
'Pay at the door',
'Cash and card accepted at the event entrance. No need to pre-pay — your registration is already confirmed.'
));
return `<p style="font-size:16px;font-weight:700;color:#0f172a;margin:32px 0 8px 0;font-family:${ff}">How to pay</p>
<p style="font-size:13px;color:#64748b;margin:0 0 16px 0;font-family:${ff}">Choose any of the following payment methods:</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">${options.join('')}</table>
<p style="font-size:13px;color:#64748b;margin:16px 0 0 0;font-family:${ff}">Your tickets will be emailed once your payment is confirmed.</p>`;
}
// ─── Registration confirmation (user self-registered via website) ──────────────
function buildRegistrationConfirmation(reg, { isNew = true } = {}) {
const org = getOrg();
const eventTitle = reg.event?.title || 'the event';
const eventDate = reg.event?.startDate ? fmtDateShort(reg.event.startDate) : '';
const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const balance = Math.max(totalDue - totalPaid, 0);
const isUserActive = reg.user?.isActive;
const heading = isNew ? 'Registration confirmed!' : 'Registration updated';
const subtext = isNew
? `Thank you for registering for <strong>${eventTitle}</strong>.`
: `Your registration for <strong>${eventTitle}</strong> has been updated.`;
const subject = isNew ? `Registration confirmed ${eventTitle}` : `Registration updated ${eventTitle}`;
const preheader = isNew
? `You\'re registered for ${eventTitle}${eventDate ? ' on ' + eventDate : ''}!`
: `Your registration for ${eventTitle} has been updated.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">${heading}</p>
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">${isNew ? 'Your spot is reserved' : 'Changes saved'}</p>
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${reg.user?.name || 'there'}</strong>,</p>
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">${subtext}${eventDate ? ` — <strong>${eventDate}</strong>` : ''}</p>
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 12px 0;font-family:${ff}">Your registration</p>
${selectionsTable(reg.registrationOptions)}
${financialSummary(totalDue, totalPaid, balance)}
${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })}
${accountCta(isUserActive, org.url)}`;
const itemsText = (reg.registrationOptions || []).map(ro => `${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`).join('\n');
const text = `${heading}\n\nHi ${reg.user?.name || 'there'},\n\n${isNew ? `You are registered for ${eventTitle}` : `Your registration for ${eventTitle} has been updated`}${eventDate ? ' on ' + eventDate : ''}.\n\nYour selections:\n${itemsText || ' —'}\n\nTotal due: ${fmtAmount(totalDue)}\nAmount paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${balance > 0 ? `Payment options:\n 1. On our website: ${org.url}\n 2. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.` : 'No payment required — your tickets have been sent separately.'}\n\n${org.name}${org.email}\n${org.url}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// ─── Registration confirmation (admin / self-service / at-door) ───────────────
function buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink = null, formRequired = false, isNew = true } = {}) {
const org = getOrg();
const eventTitle = reg.event?.title || 'the event';
const eventDate = reg.event?.startDate ? fmtDateShort(reg.event.startDate) : '';
const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const balance = Math.max(totalDue - totalPaid, 0);
const isUserActive = reg.user?.isActive;
const heading = isNew ? 'Registration confirmed!' : 'Registration updated';
const subtext = isNew
? `You have been registered for <strong>${eventTitle}</strong>.`
: `Your registration for <strong>${eventTitle}</strong> has been updated.`;
const subject = isNew ? `Registration confirmed ${eventTitle}` : `Registration updated ${eventTitle}`;
const preheader = isNew
? `You\'re registered for ${eventTitle}${eventDate ? ' on ' + eventDate : ''}!`
: `Your registration for ${eventTitle} has been updated.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">${heading}</p>
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">${isNew ? 'Your spot is reserved' : 'Changes saved'}</p>
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${reg.user?.name || 'there'}</strong>,</p>
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">${subtext}${eventDate ? ` The event takes place on <strong>${eventDate}</strong>.` : ''}</p>
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 12px 0;font-family:${ff}">Your registration</p>
${selectionsTable(reg.registrationOptions)}
${financialSummary(totalDue, totalPaid, balance)}
${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })}
${accountCta(isUserActive, org.url)}`;
const itemsText = (reg.registrationOptions || []).map(ro => `${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`).join('\n');
const payText = balance > 0
? `Payment options:\n${yocoLink ? ` 1. Pay online (Yoco): ${yocoLink}\n (Already paid? Ignore this option)\n` : ''} ${yocoLink ? '2' : '1'}. On our website: ${org.url}\n ${yocoLink ? '3' : '2'}. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.`
: formRequired
? 'An attendee form is required before your tickets are issued. Please complete it at the registration desk.'
: 'No payment required — your tickets have been sent in a separate email.';
const text = `${heading}\n\nHi ${reg.user?.name || 'there'},\n\n${isNew ? `You have been registered for ${eventTitle}` : `Your registration for ${eventTitle} has been updated`}${eventDate ? ' on ' + eventDate : ''}.\n\nYour selections:\n${itemsText || ' —'}\n\nTotal due: ${fmtAmount(totalDue)}\nAmount paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${payText}\n\n${org.name}${org.email}\n${org.url}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// ─── Admin notification (internal) ───────────────────────────────────────────
function buildRegistrationAdminNotice(reg, { isNew = true, isUpdated = false } = {}) {
const org = getOrg();
const eventTitle = reg.event?.title || 'Event';
const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const balance = Math.max(totalDue - totalPaid, 0);
const verb = isUpdated ? 'updated' : (isNew ? 'created' : 'modified');
const subject = isUpdated
? `Registration updated: ${eventTitle}${reg.user?.name || 'Unknown'}`
: `New registration: ${eventTitle}${reg.user?.name || 'Unknown'}`;
const itemRows = (reg.registrationOptions || []).map(ro =>
`<tr>
<td style="padding:8px 12px;font-size:13px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${ro.eventOption?.name || 'Option'}</td>
<td style="padding:8px 12px;font-size:13px;color:#374151;text-align:center;font-family:${ff};border-bottom:1px solid #f1f5f9">×${ro.quantity}</td>
<td style="padding:8px 12px;font-size:13px;color:#374151;text-align:right;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}</td>
</tr>`).join('');
const body = `
<p style="font-size:20px;font-weight:800;color:#0f172a;margin:0 0 4px 0">Registration ${verb}</p>
<p style="font-size:13px;color:#64748b;margin:0 0 28px 0">${org.name} — internal notification</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 24px 0">
<tr style="background:#f8fafc">
<td colspan="2" style="padding:12px 16px;font-size:12px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Registrant details</td>
</tr>
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;width:30%;font-family:${ff};border-top:1px solid #f1f5f9">Name</td>
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${reg.user?.name || '—'}</td>
</tr>
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Email</td>
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${reg.user?.email || '—'}</td>
</tr>
${reg.user?.phoneNumber ? `<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Phone</td>
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${reg.user.phoneNumber}</td>
</tr>` : ''}
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Event</td>
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${eventTitle}</td>
</tr>
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Status</td>
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${reg.status || 'pending'}</td>
</tr>
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Reg ID</td>
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-family:${ff};border-top:1px solid #f1f5f9;font-size:12px">${reg.id}</td>
</tr>
</table>
${itemRows ? `<p style="font-size:14px;font-weight:700;color:#0f172a;margin:0 0 8px 0;font-family:${ff}">Items</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 16px 0">
<tr style="background:#f8fafc">
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:left;font-family:${ff}">Option</th>
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:center;font-family:${ff}">Qty</th>
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:right;font-family:${ff}">Amount</th>
</tr>${itemRows}
</table>` : ''}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:16px 0 0 0">
<tr>
<td style="font-size:13px;color:#64748b;padding:4px 0;font-family:${ff}">Total due</td>
<td style="font-size:13px;color:#374151;text-align:right;font-weight:500;padding:4px 0;font-family:${ff}">${fmtAmount(totalDue)}</td>
</tr>
<tr>
<td style="font-size:13px;color:#64748b;padding:4px 0;font-family:${ff}">Paid</td>
<td style="font-size:13px;color:#374151;text-align:right;font-weight:500;padding:4px 0;font-family:${ff}">${fmtAmount(totalPaid)}</td>
</tr>
<tr>
<td style="font-size:14px;font-weight:700;color:#0f172a;padding:8px 0 0 0;border-top:1px solid #e2e8f0;font-family:${ff}">Balance</td>
<td style="font-size:14px;font-weight:700;color:#${balance <= 0 ? '059669' : '0f172a'};text-align:right;padding:8px 0 0 0;border-top:1px solid #e2e8f0;font-family:${ff}">${fmtAmount(balance)}</td>
</tr>
</table>`;
const to = buildEventNotifyRecipientList(reg.event);
const text = `Registration ${verb}\n\nEvent: ${eventTitle}\nName: ${reg.user?.name || '—'}\nEmail: ${reg.user?.email || '—'}\nStatus: ${reg.status || 'pending'}\nTotal due: ${fmtAmount(totalDue)} | Paid: ${fmtAmount(totalPaid)} | Balance: ${fmtAmount(balance)}\nReg ID: ${reg.id}`;
return { to, subject, text, html: emailWrapper(body) };
}
// ─── Payment receipt ──────────────────────────────────────────────────────────
function buildPaymentReceipt(payment) {
const org = getOrg();
const isReg = !!payment.registrationId && payment.registration;
const eventTitle = isReg
? (payment.registration?.event?.title || 'the event')
: (payment.event?.title || 'the event');
if (isReg) {
const reg = payment.registration;
const totalDue = computeRegistrationTotalDue(reg, payment.createdAt || new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const balance = Math.max(totalDue - totalPaid, 0);
const isUserActive = reg.user?.isActive;
const subject = `Payment received ${eventTitle}`;
const preheader = `We received your payment of ${fmtAmount(payment.amount)} for ${eventTitle}.`;
const historyRows = (reg.payments || [])
.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
.map(p => `<tr>
<td style="padding:8px 12px;font-size:13px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtDate(p.createdAt)}</td>
<td style="padding:8px 12px;font-size:13px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${p.method || '—'}</td>
<td style="padding:8px 12px;font-size:13px;color:#374151;text-align:right;font-weight:600;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount(p.amount)}</td>
</tr>`).join('');
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Payment received</p>
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">Thank you — we've got your payment</p>
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${payment.user?.name || 'there'}</strong>,</p>
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
We received your payment of <strong>${fmtAmount(payment.amount)}</strong> for <strong>${eventTitle}</strong>.
</p>
${callout(`<strong style="font-size:15px">${fmtAmount(payment.amount)} received</strong><br/>
<span style="font-size:13px">Payment method: ${payment.method || '—'} &bull; Date: ${fmtDate(payment.createdAt)}</span>`,
'success')}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:24px 0">
<tr>
<td style="font-size:13px;color:#64748b;padding:5px 0;font-family:${ff}">Total due</td>
<td style="font-size:13px;text-align:right;font-weight:500;color:#374151;padding:5px 0;font-family:${ff}">${fmtAmount(totalDue)}</td>
</tr>
<tr>
<td style="font-size:13px;color:#64748b;padding:5px 0;font-family:${ff}">Total paid</td>
<td style="font-size:13px;text-align:right;font-weight:500;color:#374151;padding:5px 0;font-family:${ff}">${fmtAmount(totalPaid)}</td>
</tr>
<tr>
<td style="font-size:14px;font-weight:700;color:#0f172a;padding:10px 0 5px 0;border-top:1px solid #e2e8f0;font-family:${ff}">${balance <= 0 ? 'Fully paid ✓' : 'Balance remaining'}</td>
<td style="font-size:14px;font-weight:700;color:#${balance <= 0 ? '059669' : '0f172a'};text-align:right;padding:10px 0 5px 0;border-top:1px solid #e2e8f0;font-family:${ff}">${fmtAmount(balance)}</td>
</tr>
</table>
${historyRows ? `<p style="font-size:14px;font-weight:700;color:#0f172a;margin:24px 0 8px 0;font-family:${ff}">Payment history</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 24px 0">
<tr style="background:#f8fafc">
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:left;font-family:${ff}">Date</th>
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:left;font-family:${ff}">Method</th>
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:right;font-family:${ff}">Amount</th>
</tr>${historyRows}
</table>` : ''}
${balance <= 0
? callout('<strong>You\'re fully paid!</strong> Your tickets have been emailed to you separately.', 'success')
: ''}
${accountCta(isUserActive, org.url)}`;
const text = `Payment received\n\nHi ${payment.user?.name || 'there'},\n\nWe received your payment of ${fmtAmount(payment.amount)} for ${eventTitle}.\n\nTotal due: ${fmtAmount(totalDue)}\nTotal paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\nThank you!\n\n${org.name}${org.email}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// Donation receipt
const subject = `Donation received ${eventTitle}`;
const preheader = `Thank you for your donation of ${fmtAmount(payment.amount)} to ${eventTitle}.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Thank you for your donation!</p>
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">Your generosity makes a difference</p>
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${payment.user?.name || 'there'}</strong>,</p>
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
We received your donation of <strong>${fmtAmount(payment.amount)}</strong> to <strong>${eventTitle}</strong>. Your support means the world to us.
</p>
${callout(`<strong style="font-size:15px">${fmtAmount(payment.amount)} donated</strong><br/>
<span style="font-size:13px">Method: ${payment.method || '—'} &bull; Date: ${fmtDate(payment.createdAt)}</span>`,
'success')}
<p style="margin:24px 0 0 0;font-size:13px;color:#94a3b8;font-family:${ff}">We appreciate your generous support. Thank you!</p>`;
const text = `Thank you for your donation!\n\nHi ${payment.user?.name || 'there'},\n\nWe received your donation of ${fmtAmount(payment.amount)} to ${eventTitle}.\n\nMethod: ${payment.method || '—'}\nDate: ${fmtDate(payment.createdAt)}\n\nThank you!\n\n${org.name}${org.email}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// ─── Donation applied to a registration ────────────────────────────────────────
//
// Distinct from buildPaymentReceipt: this is sent to the REGISTRANT when staff apply
// someone else's donation to their registration — they didn't pay anything themselves,
// so "payment received" wording would be wrong. Kept anonymous (no donor name) by design.
function buildDonationAppliedToRegistrant(payment) {
const org = getOrg();
const reg = payment.registration;
const eventTitle = reg?.event?.title || 'the event';
const totalDue = computeRegistrationTotalDue(reg, payment.createdAt || new Date());
const totalPaid = (reg?.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const balance = Math.max(totalDue - totalPaid, 0);
const isUserActive = reg?.user?.isActive;
const subject = `A donation was applied to your registration ${eventTitle}`;
const preheader = `A donation of ${fmtAmount(payment.amount)} was applied to your registration for ${eventTitle}.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">A donation was applied to your registration</p>
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">Good news about your balance</p>
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${reg?.user?.name || 'there'}</strong>,</p>
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
A donation of <strong>${fmtAmount(payment.amount)}</strong> was applied to your registration for <strong>${eventTitle}</strong> by our team.
</p>
${callout(`<strong style="font-size:15px">${fmtAmount(payment.amount)} applied</strong><br/>
<span style="font-size:13px">Date: ${fmtDate(payment.createdAt)}</span>`,
'success')}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:24px 0">
<tr>
<td style="font-size:13px;color:#64748b;padding:5px 0;font-family:${ff}">Total due</td>
<td style="font-size:13px;text-align:right;font-weight:500;color:#374151;padding:5px 0;font-family:${ff}">${fmtAmount(totalDue)}</td>
</tr>
<tr>
<td style="font-size:13px;color:#64748b;padding:5px 0;font-family:${ff}">Total paid</td>
<td style="font-size:13px;text-align:right;font-weight:500;color:#374151;padding:5px 0;font-family:${ff}">${fmtAmount(totalPaid)}</td>
</tr>
<tr>
<td style="font-size:14px;font-weight:700;color:#0f172a;padding:10px 0 5px 0;border-top:1px solid #e2e8f0;font-family:${ff}">${balance <= 0 ? 'Fully paid ✓' : 'Balance remaining'}</td>
<td style="font-size:14px;font-weight:700;color:#${balance <= 0 ? '059669' : '0f172a'};text-align:right;padding:10px 0 5px 0;border-top:1px solid #e2e8f0;font-family:${ff}">${fmtAmount(balance)}</td>
</tr>
</table>
${balance <= 0
? callout('<strong>You\'re fully paid!</strong> Your tickets have been emailed to you separately.', 'success')
: ''}
${accountCta(isUserActive, org.url)}`;
const text = `A donation was applied to your registration\n\nHi ${reg?.user?.name || 'there'},\n\nA donation of ${fmtAmount(payment.amount)} was applied to your registration for ${eventTitle} by our team.\n\nTotal due: ${fmtAmount(totalDue)}\nTotal paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${org.name}${org.email}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// ─── Payment admin notification ───────────────────────────────────────────────
function buildPaymentAdminNotice(payment) {
const isReg = !!payment.registrationId && payment.registration;
const eventTitle = isReg ? (payment.registration?.event?.title || 'Event') : (payment.event?.title || 'Event');
const payerName = payment.user?.name || '—';
const payerEmail = payment.user?.email || '—';
const subject = `Payment recorded: ${fmtAmount(payment.amount)}${payerName} (${eventTitle})`;
const type = isReg ? 'Registration payment' : 'Donation';
const body = `
<p style="font-size:20px;font-weight:800;color:#0f172a;margin:0 0 4px 0">Payment recorded</p>
<p style="font-size:13px;color:#64748b;margin:0 0 28px 0">Internal notification</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 24px 0">
<tr style="background:#f8fafc">
<td colspan="2" style="padding:12px 16px;font-size:12px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Payment details</td>
</tr>
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;width:30%;font-family:${ff};border-top:1px solid #f1f5f9">Amount</td>
<td style="padding:10px 16px;font-size:15px;color:#059669;font-weight:800;font-family:${ff};border-top:1px solid #f1f5f9">${fmtAmount(payment.amount)}</td>
</tr>
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Type</td>
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${type}</td>
</tr>
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Payer</td>
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${payerName} &lt;${payerEmail}&gt;</td>
</tr>
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Event</td>
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${eventTitle}</td>
</tr>
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Method</td>
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${payment.method || '—'}</td>
</tr>
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Date</td>
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${fmtDate(payment.createdAt)}</td>
</tr>
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Payment ID</td>
<td style="padding:10px 16px;font-size:12px;color:#94a3b8;font-family:${ff};border-top:1px solid #f1f5f9">${payment.id}</td>
</tr>
${payment.externalId ? `<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">External ID</td>
<td style="padding:10px 16px;font-size:12px;color:#94a3b8;font-family:${ff};border-top:1px solid #f1f5f9">${payment.externalId}</td>
</tr>` : ''}
</table>`;
const event = isReg ? payment.registration?.event : payment.event;
const to = buildEventNotifyRecipientList(event);
const text = `Payment recorded\n\nAmount: ${fmtAmount(payment.amount)}\nType: ${type}\nPayer: ${payerName} <${payerEmail}>\nEvent: ${eventTitle}\nMethod: ${payment.method || '—'}\nDate: ${fmtDate(payment.createdAt)}\nID: ${payment.id}`;
return { to, subject, text, html: emailWrapper(body) };
}
// ─── Refund email ─────────────────────────────────────────────────────────────
function buildRefundEmail(payment) {
const org = getOrg();
const user = payment.user;
const amt = Math.abs(payment.amount || 0);
const eventTitle = payment.registration?.event?.title || payment.event?.title || 'the event';
const subject = `Refund processed ${fmtAmount(amt)} for ${eventTitle}`;
const preheader = `Your refund of ${fmtAmount(amt)} for ${eventTitle} has been processed.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Refund processed</p>
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">Your refund is on its way</p>
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${user?.name || 'there'}</strong>,</p>
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
A refund of <strong>${fmtAmount(amt)}</strong> has been processed for <strong>${eventTitle}</strong>.
${payment.status ? `<br/>Reason: ${payment.status}` : ''}
</p>
${callout(`<strong style="font-size:15px">${fmtAmount(amt)} refunded</strong><br/>
<span style="font-size:13px">Method: ${payment.method || 'original payment method'} &bull; Date: ${fmtDate(payment.createdAt)}</span>`,
'info')}
<p style="margin:24px 0 0 0;font-size:14px;color:#374151;font-family:${ff}">
Refunds may take a few business days to appear depending on your bank and payment method.
If you have any questions, contact us at <a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.
</p>`;
const text = `Refund processed\n\nHi ${user?.name || 'there'},\n\nA refund of ${fmtAmount(amt)} for ${eventTitle} has been processed.\n\nMethod: ${payment.method || 'original method'}\nDate: ${fmtDate(payment.createdAt)}\n\n${org.name}${org.email}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// ─── Daily event summary ──────────────────────────────────────────────────────
function buildDailySummary(ev, registrations, payments, now) {
const org = getOrg();
const subject = `Daily summary: ${ev.title}${new Date(now).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}`;
const regRows = registrations.map(r => {
const totalDue = computeRegistrationTotalDue(r, now);
const totalPaid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const balance = Math.max(totalDue - totalPaid, 0);
return { name: r.user?.name || '?', email: r.user?.email || '', status: r.status, totalDue, totalPaid, balance };
});
const payRows = payments.map(p => ({
date: fmtDate(p.createdAt),
amount: p.amount,
method: p.method || '—',
isDonation: p.isDonation || !p.registrationId,
payerName: p.registration?.user?.name || p.user?.name || '?',
payerEmail: p.user?.email || p.registration?.user?.email || '',
}));
const totalRegistrations = regRows.length;
const totalRevenue = payRows.reduce((s, p) => s + (p.amount || 0), 0);
const paidCount = regRows.filter(r => r.status === 'paid').length;
const pendingCount = regRows.filter(r => r.status === 'pending' || r.status === 'partial_paid').length;
const statsRow = (label, value, color = '#1e293b') =>
`<td style="text-align:center;padding:16px;border-right:1px solid #f1f5f9">
<div style="font-size:24px;font-weight:800;color:${color};font-family:${ff}">${value}</div>
<div style="font-size:12px;color:#64748b;margin-top:4px;font-family:${ff}">${label}</div>
</td>`;
const regTableHtml = regRows.length
? `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;font-size:13px">
<tr style="background:#f8fafc">
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Name</th>
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Email</th>
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Status</th>
<th style="padding:10px 12px;text-align:right;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Total</th>
<th style="padding:10px 12px;text-align:right;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Paid</th>
<th style="padding:10px 12px;text-align:right;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Balance</th>
</tr>
${regRows.map((r, i) => `<tr style="background:${i % 2 === 0 ? '#ffffff' : '#f8fafc'}">
<td style="padding:10px 12px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${r.name}</td>
<td style="padding:10px 12px;color:#64748b;font-family:${ff};border-top:1px solid #f1f5f9;font-size:12px">${r.email}</td>
<td style="padding:10px 12px;border-top:1px solid #f1f5f9">
<span style="background:${r.status === 'paid' ? '#dcfce7' : r.status === 'partial_paid' ? '#fef9c3' : '#f1f5f9'};color:${r.status === 'paid' ? '#166534' : r.status === 'partial_paid' ? '#854d0e' : '#475569'};padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-family:${ff}">${r.status}</span>
</td>
<td style="padding:10px 12px;text-align:right;color:#374151;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${fmtAmount(r.totalDue)}</td>
<td style="padding:10px 12px;text-align:right;color:#374151;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${fmtAmount(r.totalPaid)}</td>
<td style="padding:10px 12px;text-align:right;color:${r.balance > 0 ? '#b45309' : '#059669'};font-weight:700;font-family:${ff};border-top:1px solid #f1f5f9">${fmtAmount(r.balance)}</td>
</tr>`).join('')}
</table>`
: `<p style="color:#94a3b8;font-size:14px;margin:0;font-family:${ff}">No registrations yet.</p>`;
const payTableHtml = payRows.length
? `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;font-size:13px">
<tr style="background:#f8fafc">
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Date</th>
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Payer</th>
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Type</th>
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Method</th>
<th style="padding:10px 12px;text-align:right;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Amount</th>
</tr>
${payRows.map((p, i) => `<tr style="background:${i % 2 === 0 ? '#ffffff' : '#f8fafc'}">
<td style="padding:10px 12px;color:#64748b;font-family:${ff};border-top:1px solid #f1f5f9;font-size:12px">${p.date}</td>
<td style="padding:10px 12px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${p.payerName}${p.payerEmail ? `<br/><span style="font-size:11px;color:#94a3b8">${p.payerEmail}</span>` : ''}</td>
<td style="padding:10px 12px;border-top:1px solid #f1f5f9">
<span style="background:${p.isDonation ? '#eff6ff' : '#f0fdf4'};color:${p.isDonation ? '#1e40af' : '#166534'};padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-family:${ff}">${p.isDonation ? 'Donation' : 'Registration'}</span>
</td>
<td style="padding:10px 12px;color:#64748b;font-family:${ff};border-top:1px solid #f1f5f9">${p.method}</td>
<td style="padding:10px 12px;text-align:right;color:#059669;font-weight:700;font-family:${ff};border-top:1px solid #f1f5f9">${fmtAmount(p.amount)}</td>
</tr>`).join('')}
</table>`
: `<p style="color:#94a3b8;font-size:14px;margin:0;font-family:${ff}">No payments recorded yet.</p>`;
const body = `
<p style="font-size:20px;font-weight:800;color:#0f172a;margin:0 0 4px 0;font-family:${ff}">Daily Summary</p>
<p style="font-size:13px;color:#64748b;margin:0 0 4px 0;font-family:${ff}">${new Date(now).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}</p>
<p style="font-size:16px;font-weight:700;color:#1e293b;margin:0 0 24px 0;font-family:${ff}">${ev.title}</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 32px 0;text-align:center">
<tr>
${statsRow('Total registrations', totalRegistrations)}
${statsRow('Confirmed paid', paidCount, '#059669')}
${statsRow('Awaiting payment', pendingCount, '#d97706')}
<td style="text-align:center;padding:16px">
<div style="font-size:24px;font-weight:800;color:#2563eb;font-family:${ff}">${fmtAmount(totalRevenue)}</div>
<div style="font-size:12px;color:#64748b;margin-top:4px;font-family:${ff}">Total collected</div>
</td>
</tr>
</table>
${divider()}
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 16px 0;font-family:${ff}">Registrations</p>
${regTableHtml}
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:32px 0 16px 0;font-family:${ff}">Payments &amp; Donations</p>
${payTableHtml}
<p style="font-size:12px;color:#94a3b8;margin:24px 0 0 0;font-family:${ff}">
Event starts: ${fmtDate(ev.startDate)} &bull; Event ends: ${fmtDate(ev.endDate)}
</p>`;
const text = [
`Daily Summary — ${ev.title}`,
new Date(now).toLocaleDateString(),
'',
`Registrations: ${totalRegistrations} | Paid: ${paidCount} | Pending: ${pendingCount} | Revenue: ${fmtAmount(totalRevenue)}`,
'',
'Registrations:',
...(regRows.length ? regRows.map(r => ` ${r.name} <${r.email}> — ${r.status} — due: ${fmtAmount(r.totalDue)} paid: ${fmtAmount(r.totalPaid)} balance: ${fmtAmount(r.balance)}`) : [' (none)']),
'',
'Payments:',
...(payRows.length ? payRows.map(p => ` ${p.date}${fmtAmount(p.amount)} via ${p.method}${p.isDonation ? 'Donation' : 'Registration'}${p.payerName}`) : [' (none)']),
].join('\n');
return { subject, text, html: emailWrapper(body) };
}
// ─── Send functions ───────────────────────────────────────────────────────────
async function sendRegistrationEmails(registrationId) {
try {
const reg = await loadRegistrationFull(registrationId);
if (!reg) return;
const { shouldEmail, waText } = require('./notify');
const { buildWARegistration } = require('./waMessages');
const { computeRegistrationTotalDue } = require('./pricing');
const sends = [];
const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
// Email: only for real addresses (skip guest.local placeholders)
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
const msg = buildRegistrationConfirmation(reg, { isNew: true });
if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text }));
}
// WhatsApp: always attempt — waText checks canWhatsApp (preference + valid phone) internally
sends.push(waText(reg.user, buildWARegistration(reg, { isNew: true, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
const adminMsg = buildRegistrationAdminNotice(reg, { isNew: true });
if (adminMsg.to && adminMsg.to.length) {
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
}
await Promise.all(sends);
} catch (e) {
console.error('Failed to send registration emails:', e);
}
}
async function sendRegistrationUpdatedEmails(registrationId) {
try {
const reg = await loadRegistrationFull(registrationId);
if (!reg) return;
const { shouldEmail, waText } = require('./notify');
const { buildWARegistration } = require('./waMessages');
const { computeRegistrationTotalDue } = require('./pricing');
const sends = [];
const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
const msg = buildRegistrationConfirmation(reg, { isNew: false });
if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text }));
}
sends.push(waText(reg.user, buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
const adminMsg = buildRegistrationAdminNotice(reg, { isNew: false, isUpdated: true });
if (adminMsg.to && adminMsg.to.length) {
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
}
await Promise.all(sends);
} catch (e) {
console.error('Failed to send registration updated emails:', e);
}
}
async function sendSelfServiceRegistrationEmails(registrationId, { paymentUrl = null, formRequired = false, isNew = true } = {}) {
try {
const reg = await loadRegistrationFull(registrationId);
if (!reg) return;
const { shouldEmail, waText } = require('./notify');
const { buildWARegistration } = require('./waMessages');
const { computeRegistrationTotalDue } = require('./pricing');
const sends = [];
const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
const msg = buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink: paymentUrl, formRequired, isNew });
if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text }));
}
sends.push(waText(reg.user, buildWARegistration(reg, { isNew, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
const adminMsg = buildRegistrationAdminNotice(reg, { isNew, isUpdated: !isNew });
if (adminMsg.to && adminMsg.to.length) {
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
}
await Promise.all(sends);
} catch (e) {
console.error('Failed to send self-service registration emails:', e);
}
}
async function sendPaymentEmails(paymentId) {
try {
const payment = await loadPaymentFull(paymentId);
if (!payment) return;
const user = payment.registration?.user || payment.user;
const { shouldEmail, waText, waTextAny } = require('./notify');
const { buildWAPayment } = require('./waMessages');
const sends = [];
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
if (hasValidEmail) {
const msg = buildPaymentReceipt(payment);
if (shouldEmail(user)) sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text }));
}
// WhatsApp: respect preference when email is available; use as unconditional fallback when it isn't
if (hasValidEmail) {
sends.push(waText(user, buildWAPayment(payment)));
} else {
sends.push(waTextAny(user, buildWAPayment(payment)));
}
const hasEvent = !!(payment.registration?.eventId || payment.eventId);
if (hasEvent) {
const adminMsg = buildPaymentAdminNotice(payment);
if (adminMsg.to && adminMsg.to.length) {
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
}
}
await Promise.all(sends);
} catch (e) {
console.error('Failed to send payment emails:', e);
}
}
async function sendRefundEmail(refundPaymentId) {
try {
const payment = await loadPaymentFull(refundPaymentId);
if (!payment) return;
const user = payment.user;
if (!user?.email || user.email.endsWith('@guest.local')) return;
const msg = buildRefundEmail(payment);
const sends = [sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text })];
const adminMsg = buildPaymentAdminNotice(payment);
if (adminMsg.to && adminMsg.to.length) {
sends.push(sendMail({ to: adminMsg.to.join(','), subject: `Refund: ${adminMsg.subject}`, html: adminMsg.html, text: adminMsg.text }));
}
await Promise.all(sends);
} catch (e) {
console.error('Failed to send refund email:', e);
}
}
// Sent when staff apply a donation to someone's registration. Only the registrant is
// notified (anonymously, per design) — the donor already received their donation-received
// notification when the donation was originally made, so they are deliberately not emailed
// again here, and any leftover/unassigned remainder from a partial allocation is silent too.
async function sendDonationAssignmentEmails(paymentId) {
try {
const payment = await loadPaymentFull(paymentId);
if (!payment || !payment.registration) return;
const user = payment.registration.user;
const { shouldEmail, waText, waTextAny } = require('./notify');
const { buildWADonationAppliedToRegistrant } = require('./waMessages');
const sends = [];
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
if (hasValidEmail) {
const msg = buildDonationAppliedToRegistrant(payment);
if (shouldEmail(user)) sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text }));
}
if (hasValidEmail) {
sends.push(waText(user, buildWADonationAppliedToRegistrant(payment)));
} else {
sends.push(waTextAny(user, buildWADonationAppliedToRegistrant(payment)));
}
const adminMsg = buildPaymentAdminNotice(payment);
if (adminMsg.to && adminMsg.to.length) {
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
}
await Promise.all(sends);
} catch (e) {
console.error('Failed to send donation-assignment emails:', e);
}
}
async function sendDailyEventSummaries(now = new Date()) {
try {
const today = new Date(now);
const notifyInclude = { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } };
let events = await prisma.event.findMany({
where: { isActive: true, startDate: { gte: today } },
include: notifyInclude,
orderBy: { startDate: 'asc' },
});
try {
events = await prisma.event.findMany({
where: { isActive: true, startDate: { gte: today }, goLiveAt: { lte: today } },
include: notifyInclude,
orderBy: { startDate: 'asc' },
});
} catch {}
await Promise.allSettled(events.map(async ev => {
const registrations = await prisma.registration.findMany({
where: { eventId: ev.id },
include: {
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
payments: true,
},
orderBy: { createdAt: 'asc' },
});
const payments = await prisma.payment.findMany({
where: { OR: [{ eventId: ev.id }, { registration: { eventId: ev.id } }] },
include: {
user: { select: { id: true, name: true, email: true } },
registration: { select: { id: true, user: { select: { id: true, name: true, email: true } } } },
},
orderBy: { createdAt: 'asc' },
});
const { subject, text, html } = buildDailySummary(ev, registrations, payments, now);
const to = buildEventNotifyRecipientList(ev);
if (to.length) await sendMail({ to: to.join(','), subject, html, text });
}));
} catch (e) {
console.error('Failed to send daily event summaries:', e);
}
}
module.exports = {
sendRegistrationEmails,
sendRegistrationUpdatedEmails,
sendPaymentEmails,
sendDailyEventSummaries,
sendRefundEmail,
sendSelfServiceRegistrationEmails,
sendDonationAssignmentEmails,
};
+82
View File
@@ -0,0 +1,82 @@
/**
* Routing helpers for sending notifications based on a user's
* notificationPreference (email | whatsapp | both).
*
* Security-critical messages (password reset, login alert, password changed,
* account closed) always send email regardless of preference, and additionally
* send a WhatsApp message when preference includes it.
*
* Informational messages (registration, payment, tickets) respect the
* preference fully.
*/
const { isValidZAPhone, sendText, sendPdf } = require('./whatsapp');
/** Returns true when the user should receive a WhatsApp notification. */
function canWhatsApp(user) {
if (!user) return false;
const pref = user.notificationPreference || 'email';
return (pref === 'whatsapp' || pref === 'both') && isValidZAPhone(user.phoneNumber);
}
/** Returns true when the user should receive an email notification. */
function shouldEmail(user) {
if (!user) return false;
const pref = user.notificationPreference || 'email';
return pref === 'email' || pref === 'both';
}
/**
* Fire-and-forget WhatsApp text to a user.
* Silently skips if the user can't receive WhatsApp.
*/
async function waText(user, message) {
if (!canWhatsApp(user)) return;
try {
await sendText(user.phoneNumber, message);
} catch (e) {
console.warn('[notify] WhatsApp text failed:', e?.response?.data?.message || e.message);
}
}
/**
* Fire-and-forget WhatsApp PDF to a user.
* Silently skips if the user can't receive WhatsApp.
*/
async function waPdf(user, localPdfPath, filename, caption) {
if (!canWhatsApp(user)) return;
try {
await sendPdf(user.phoneNumber, localPdfPath, filename, caption);
} catch (e) {
console.warn('[notify] WhatsApp PDF failed:', e?.response?.data?.message || e.message);
}
}
/**
* Like waText but bypasses the notification preference check.
* Use as a fallback when email is not available (guest / no valid email),
* so the user still receives notifications via WhatsApp if they have a phone.
*/
async function waTextAny(user, message) {
if (!user || !isValidZAPhone(user.phoneNumber)) return;
try {
await sendText(user.phoneNumber, message);
} catch (e) {
console.warn('[notify] WhatsApp text (fallback) failed:', e?.response?.data?.message || e.message);
}
}
/**
* Like waPdf but bypasses the notification preference check.
* Use as a fallback when email is not available (guest / no valid email).
*/
async function waPdfAny(user, localPdfPath, filename, caption) {
if (!user || !isValidZAPhone(user.phoneNumber)) return;
try {
await sendPdf(user.phoneNumber, localPdfPath, filename, caption);
} catch (e) {
console.warn('[notify] WhatsApp PDF (fallback) failed:', e?.response?.data?.message || e.message);
}
}
module.exports = { canWhatsApp, shouldEmail, waText, waPdf, waTextAny, waPdfAny };
+248
View File
@@ -0,0 +1,248 @@
/**
* Early-bird pricing utility
*
* Rules:
* - Base price is eventOption.price
* - resolveOptionPrice: picks the cheapest applicable tier, checking both deadline AND stock limits.
* Used at registration-creation time and again at payment-initiation time.
* - getEffectiveUnitPrice: deadline-only check; used for line-item display in Yoco checkout and
* as a fallback for legacy RegistrationOption rows that have no priceSnapshot.
* - computeRegistrationTotalDue: uses priceSnapshot when present (authoritative after
* refreshPricingForRegistration runs), otherwise falls back to getEffectiveUnitPrice.
* - refreshPricingForRegistration: re-runs resolveOptionPrice for every RegistrationOption
* that has an appliedTierId; updates priceSnapshot + appliedTierId in the DB if the tier
* is now expired or its stock is exhausted.
*/
const prisma = require('../config/db');
/**
* Compute effective unit price for an event option at a given time considering early-bird tiers.
* Only checks deadline (not stock). Used for display and as a legacy fallback.
*
* @param {object} eventOption - includes price:number and earlyBirdTiers?:Array<{deadline:string|Date, price:number}>
* @param {Date|string|null} referenceTime - usually the last payment time; if null, falls back to atTime
* @param {Date} atTime - payment/evaluation time (e.g., now or payment.createdAt)
* @returns {number}
*/
function getEffectiveUnitPrice(eventOption, referenceTime, atTime) {
if (!eventOption) return 0;
const base = Number(eventOption.price || 0);
const tiers = Array.isArray(eventOption.earlyBirdTiers) ? eventOption.earlyBirdTiers.slice() : [];
if (!tiers.length) return base;
const t = atTime ? new Date(atTime) : new Date();
// If no referenceTime (no payments yet), use atTime so early-bird applies based on "now"
const ref = referenceTime ? new Date(referenceTime) : t;
// Only tiers whose deadline is after BOTH reference and payment/evaluation times qualify
const applicable = tiers
.map(x => ({ ...x, deadline: new Date(x.deadline) }))
.filter(x => (ref < x.deadline) && (t < x.deadline))
.sort((a, b) => a.deadline.getTime() - b.deadline.getTime() || (a.order || 0) - (b.order || 0) || a.price - b.price);
if (applicable.length === 0) return base;
const chosen = applicable[0];
const price = Number(chosen.price);
if (!(price >= 0)) return base;
return price;
}
/**
* Resolve the best applicable early-bird tier price for an event option.
* Checks BOTH deadline AND stock limits. Cancelled registrations are excluded from stock counts.
* Tiers are sorted cheapest-first (best deal for the user); earliest deadline breaks ties.
*
* @param {object} option - EventOption with price, earlyBirdTiers[]
* @param {number} requestedQty - quantity being purchased
* @returns {Promise<{ price: number, tierId: string|null }>}
*/
async function resolveOptionPrice(option, requestedQty = 1) {
const now = new Date();
// Only option-level tiers (no variant association)
const tiers = (Array.isArray(option.earlyBirdTiers) ? option.earlyBirdTiers : [])
.filter(t => !t.variantId)
.slice();
// Sort cheapest-first; earliest deadline breaks ties
tiers.sort((a, b) => {
if (a.price !== b.price) return a.price - b.price;
return new Date(a.deadline).getTime() - new Date(b.deadline).getTime();
});
for (const tier of tiers) {
// Skip expired tiers
if (now >= new Date(tier.deadline)) continue;
// Check stock limit if one is set
if (tier.stockLimit > 0) {
const soldAgg = await prisma.registrationOption.aggregate({
where: {
appliedTierId: tier.id,
registration: { status: { not: 'cancelled' } }
},
_sum: { quantity: true }
});
const tierSold = soldAgg._sum?.quantity || 0;
if (tierSold + requestedQty > tier.stockLimit) continue; // tier exhausted — try next
}
return { price: tier.price, tierId: tier.id };
}
// No tier applicable — fall back to base option price
return { price: option.price, tierId: null };
}
/**
* Resolve the best applicable early-bird tier price for a specific variant.
* Checks tiers that have variantId matching the given variant.
* Falls back to variant.price (or option.price if variant has no override) when no tier applies.
*
* @param {object} option - EventOption with price, earlyBirdTiers[], variants[]
* @param {string} variantId
* @param {number} requestedQty
* @returns {Promise<{ price: number, tierId: string|null }>}
*/
async function resolveVariantTierPrice(option, variantId, requestedQty = 1) {
const now = new Date();
const variant = (option.variants || []).find(v => v.id === variantId);
const basePrice = (variant && variant.price !== null && variant.price !== undefined)
? Number(variant.price)
: Number(option.price || 0);
const tiers = (Array.isArray(option.earlyBirdTiers) ? option.earlyBirdTiers : [])
.filter(t => t.variantId === variantId)
.slice();
if (!tiers.length) return { price: basePrice, tierId: null };
tiers.sort((a, b) => {
if (a.price !== b.price) return a.price - b.price;
return new Date(a.deadline).getTime() - new Date(b.deadline).getTime();
});
for (const tier of tiers) {
if (now >= new Date(tier.deadline)) continue;
if (tier.stockLimit > 0) {
const soldAgg = await prisma.registrationOption.aggregate({
where: { appliedTierId: tier.id, registration: { status: { not: 'cancelled' } } },
_sum: { quantity: true }
});
const tierSold = soldAgg._sum?.quantity || 0;
if (tierSold + requestedQty > tier.stockLimit) continue;
}
return { price: tier.price, tierId: tier.id };
}
return { price: basePrice, tierId: null };
}
/**
* Re-evaluate early-bird prices for all RegistrationOptions that have an appliedTierId.
* If a tier is now expired or its stock is exhausted, the next applicable tier (or base price)
* is resolved and priceSnapshot + appliedTierId are updated in the DB.
*
* Call this before processing any payment to ensure stock-based price forfeiture is enforced.
*
* @param {string} registrationId
* @returns {Promise<{ changed: boolean }>}
*/
async function refreshPricingForRegistration(registrationId) {
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true, variants: true } }
}
}
}
});
if (!registration) return { changed: false };
let anyChanged = false;
for (const ro of registration.registrationOptions) {
// Only refresh options that were priced via a tier
if (!ro.appliedTierId) continue;
// Find the currently applied tier
const currentTier = (ro.eventOption.earlyBirdTiers || []).find(t => t.id === ro.appliedTierId);
if (currentTier && new Date() < new Date(currentTier.deadline)) {
// The tier's deadline is still in the future — honor the locked price.
continue;
}
// Deadline has passed (or tier record missing) — resolve the next applicable tier
const resolved = ro.variantId
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, ro.quantity)
: await resolveOptionPrice(ro.eventOption, ro.quantity);
const tierChanged = resolved.tierId !== ro.appliedTierId;
const priceChanged = ro.priceSnapshot !== null && Math.abs(resolved.price - ro.priceSnapshot) > 0.001;
if (tierChanged || priceChanged) {
await prisma.registrationOption.update({
where: { id: ro.id },
data: {
priceSnapshot: resolved.price,
appliedTierId: resolved.tierId
}
});
anyChanged = true;
}
}
return { changed: anyChanged };
}
/**
* Compute total due for a registration at a given time.
*
* Uses priceSnapshot when present (authoritative — set at registration time and refreshed
* before payment via refreshPricingForRegistration). Falls back to getEffectiveUnitPrice
* for legacy rows without a snapshot.
*
* @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption}
* and optionally payments[]
* @param {Date} atTime - evaluation time (used for legacy fallback only)
* @returns {number}
*/
function computeRegistrationTotalDue(registration, atTime) {
if (!registration || !Array.isArray(registration.registrationOptions)) return 0;
// Determine the last payment time (used only for the legacy getEffectiveUnitPrice fallback)
let lastPaymentAt = null;
try {
if (registration.payments && Array.isArray(registration.payments) && registration.payments.length > 0) {
lastPaymentAt = new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime())));
}
} catch {}
return registration.registrationOptions.reduce((sum, ro) => {
const qty = Number(ro.quantity || 0);
let unit;
if (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) {
// priceSnapshot is authoritative — set at registration creation and kept current
// by refreshPricingForRegistration at payment initiation time.
unit = ro.priceSnapshot;
} else {
// Fallback: legacy row without a snapshot — re-evaluate from tier deadlines
const eo = ro.eventOption || {};
unit = getEffectiveUnitPrice(eo, lastPaymentAt, atTime);
}
return sum + qty * unit;
}, 0);
}
module.exports = {
getEffectiveUnitPrice,
resolveOptionPrice,
resolveVariantTierPrice,
refreshPricingForRegistration,
computeRegistrationTotalDue,
};
+108
View File
@@ -0,0 +1,108 @@
const fs = require('fs');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const QUEUE_PATH = path.join(DATA_DIR, 'scheduled-emails.json');
function ensureStore() {
try {
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
if (!fs.existsSync(QUEUE_PATH)) fs.writeFileSync(QUEUE_PATH, JSON.stringify({ jobs: [] }, null, 2), 'utf-8');
} catch (e) {
// Best-effort; throws will surface to caller
}
}
function loadAll() {
ensureStore();
try {
const raw = fs.readFileSync(QUEUE_PATH, 'utf-8');
const data = JSON.parse(raw);
const jobs = Array.isArray(data?.jobs) ? data.jobs : [];
return jobs;
} catch (e) {
return [];
}
}
function saveAll(jobs) {
ensureStore();
const payload = { jobs: Array.isArray(jobs) ? jobs : [] };
// Simple atomic-ish write
const tmp = QUEUE_PATH + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf-8');
fs.renameSync(tmp, QUEUE_PATH);
}
/**
* Add a scheduled job
* @param {object} job { id?, eventId, createdById, scheduledAt: ISO string, payload: body for emailEventAttendees }
*/
function addJob(job) {
const now = new Date();
const id = job.id || uuidv4();
const rec = {
id,
eventId: job.eventId || null,
broadcast: !!job.broadcast,
createdById: job.createdById || null,
scheduledAt: job.scheduledAt,
createdAt: now.toISOString(),
status: 'queued', // queued | sending | sent | error
attempts: 0,
lastError: null,
payload: job.payload || {},
};
const jobs = loadAll();
jobs.push(rec);
saveAll(jobs);
return rec;
}
function listJobs(filter = {}) {
const jobs = loadAll();
// Basic filter support
return jobs.filter(j => {
if (filter.status && j.status !== filter.status) return false;
if (filter.eventId && j.eventId !== filter.eventId) return false;
return true;
});
}
function getDueJobs(now = new Date()) {
const jobs = loadAll();
const t = now instanceof Date ? now : new Date(now);
return jobs.filter(j => j.status === 'queued' && new Date(j.scheduledAt).getTime() <= t.getTime());
}
function updateJob(id, patch) {
const jobs = loadAll();
const idx = jobs.findIndex(j => j.id === id);
if (idx === -1) return null;
jobs[idx] = { ...jobs[idx], ...patch };
saveAll(jobs);
return jobs[idx];
}
function getJob(id) {
const jobs = loadAll();
return jobs.find(j => j.id === id) || null;
}
function deleteJob(id) {
const jobs = loadAll();
const filtered = jobs.filter(j => j.id !== id);
if (filtered.length === jobs.length) return false;
saveAll(filtered);
return true;
}
module.exports = {
addJob,
listJobs,
getDueJobs,
updateJob,
getJob,
deleteJob,
};
+79
View File
@@ -0,0 +1,79 @@
/**
* Lightweight in-process cache for AppSetting values.
* Refreshes every 60 seconds so changes made in the admin settings page
* take effect without a server restart.
* Falls back to environment variables when a key is not found in the DB.
*
* Encrypted keys (smtp_user, smtp_pass) are automatically decrypted on read.
*/
const prisma = require('../config/db');
const { decrypt, isEncrypted } = require('./encryption');
// Keys stored encrypted in the DB — decrypted transparently on read.
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass']);
let _cache = {};
let _lastFetch = 0;
const TTL_MS = 60 * 1000;
async function refreshIfStale() {
const now = Date.now();
if (now - _lastFetch < TTL_MS && _lastFetch > 0) return;
try {
const rows = await prisma.appSetting.findMany();
const fresh = {};
for (const r of rows) fresh[r.key] = r.value;
_cache = fresh;
_lastFetch = now;
} catch {
// DB unreachable — keep existing cache; will retry next call
}
}
/**
* Internal getter that optionally decrypts encrypted keys.
* @param {string} key
* @param {string} [envFallback]
* @returns {string}
*/
function _getSync(key, envFallback = '') {
const v = _cache[key];
const raw = (v !== undefined && v !== null && v !== '') ? v : (envFallback || '');
if (ENCRYPTED_KEYS.has(key) && isEncrypted(raw)) {
try { return decrypt(raw); } catch { return ''; }
}
return raw;
}
/**
* Async getter — always checks for a stale cache before returning.
* @param {string} key
* @param {string} [envFallback]
* @returns {Promise<string>}
*/
async function getSetting(key, envFallback = '') {
await refreshIfStale();
return _getSync(key, envFallback);
}
/**
* Sync wrapper for use in synchronous functions.
* Uses the current in-memory snapshot; relies on the cache being warmed at startup.
*/
const getSettingSync = _getSync;
/**
* Call once at startup to pre-warm the cache so synchronous callers get DB values.
*/
async function warmCache() {
await refreshIfStale();
}
/** Invalidate the cache and immediately re-warm it in the background. */
function invalidate() {
_lastFetch = 0;
refreshIfStale().catch(() => {});
}
module.exports = { getSetting, getSettingSync, warmCache, invalidate, ENCRYPTED_KEYS };
+145
View File
@@ -0,0 +1,145 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
/**
* Generate tickets for a registration when payment status is "paid".
*
* Rules:
* - Exactly ONE ticket per registrationOption (keyed by eventOptionId).
* - If duplicate registrationOptions exist for the same eventOptionId (old data),
* consolidate them: merge quantities, keep the one with scan history, delete the rest.
* - If duplicate ticket records exist for the same registrationOption (old data),
* keep the primary (scanned one, or oldest), update its quantity, delete the rest.
*
* @param {string} registrationId
* @returns {Promise<Array>} newly-created ticket records (empty when all already existed)
*/
const generateTicketsForRegistration = async (registrationId) => {
try {
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: {
include: {
eventOption: true,
tickets: { include: { usages: true }, orderBy: { createdAt: 'asc' } }
}
},
event: true,
user: true
}
});
if (!registration) throw new Error('Registration not found');
if (registration.status !== 'paid') return [];
// If event has a required form, ensure sufficient responses exist
try {
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } });
if (form && form.isRequired) {
const requiredCount = (registration.registrationOptions || [])
.filter(ro => ro.eventOption?.isMainTicket)
.reduce((s, ro) => s + (ro.quantity || 0), 0);
const responsesCount = await prisma.formResponse.count({ where: { registrationId } });
if (responsesCount < requiredCount) return [];
}
} catch (_) { /* form models unavailable — don't block */ }
// ── Step 1: Consolidate duplicate registrationOptions for the same (eventOptionId, variantId) ──
// Key on both fields so that different variants of the same option are never merged
const byOption = new Map();
for (const ro of registration.registrationOptions) {
const key = `${ro.eventOptionId}::${ro.variantId || ''}`;
if (!byOption.has(key)) byOption.set(key, []);
byOption.get(key).push(ro);
}
// For each group with duplicates, merge into the one that has tickets (or the first)
for (const [, group] of byOption) {
if (group.length <= 1) continue;
// Prefer the option that already has tickets
const withTickets = group.filter(ro => (ro.tickets || []).length > 0);
const primary = withTickets.length > 0 ? withTickets[0] : group[0];
const duplicates = group.filter(ro => ro.id !== primary.id);
// Move all tickets from duplicates to primary, then delete duplicate options
for (const dup of duplicates) {
for (const t of (dup.tickets || [])) {
await prisma.ticket.update({ where: { id: t.id }, data: { registrationOptionId: primary.id, updatedAt: new Date() } });
}
const totalMergedQty = duplicates.reduce((s, d) => s + (d.quantity || 0), 0);
await prisma.registrationOption.update({
where: { id: primary.id },
data: { quantity: (primary.quantity || 0) + totalMergedQty, }
});
await prisma.registrationOption.delete({ where: { id: dup.id } });
}
// Re-load the primary's current quantity after merge
const updated = await prisma.registrationOption.findUnique({ where: { id: primary.id } });
primary.quantity = updated?.quantity ?? primary.quantity;
// Reload tickets
primary.tickets = await prisma.ticket.findMany({
where: { registrationOptionId: primary.id },
include: { usages: true },
orderBy: { createdAt: 'asc' }
});
}
// ── Step 2: For each unique option, ensure exactly one ticket with correct qty ──
const generatedTickets = [];
// Re-read fresh list (some options may have been deleted above)
const freshOptions = await prisma.registrationOption.findMany({
where: { registrationId },
include: {
tickets: { include: { usages: true }, orderBy: { createdAt: 'asc' } }
}
});
for (const option of freshOptions) {
const targetQty = option.quantity || 1;
const existingTickets = option.tickets || [];
if (existingTickets.length === 0) {
// Create one ticket
const ticket = await prisma.ticket.create({
data: {
id: uuidv4(),
qrCode: uuidv4(),
registrationOptionId: option.id,
userId: registration.userId,
eventId: registration.eventId,
quantity: targetQty,
updatedAt: new Date()
}
});
generatedTickets.push(ticket);
continue;
}
// Pick primary: prefer scanned, otherwise oldest
const withUsages = existingTickets.filter(t => (t.usages || []).length > 0);
const primary = withUsages.length > 0 ? withUsages[0] : existingTickets[0];
// Update quantity on primary if needed
if (primary.quantity !== targetQty) {
await prisma.ticket.update({ where: { id: primary.id }, data: { quantity: targetQty, updatedAt: new Date() } });
}
// Delete unscanned duplicates
const dups = existingTickets.filter(t => t.id !== primary.id && (t.usages || []).length === 0);
if (dups.length > 0) {
await prisma.ticket.deleteMany({ where: { id: { in: dups.map(t => t.id) } } });
}
}
return generatedTickets;
} catch (error) {
console.error('Error generating tickets:', error);
throw error;
}
};
module.exports = { generateTicketsForRegistration };
+258
View File
@@ -0,0 +1,258 @@
/**
* WhatsApp-formatted message builders.
*
* Styling reference: https://faq.whatsapp.com/539178204879377/
* *bold* _italic_ ~strikethrough~ ```monospace```
* > blockquote - unordered list 1. numbered list
* # Heading 1 ## Heading 2 ### Heading 3
*/
function fmtAmount(amt) {
return `R${Number(amt || 0).toFixed(2)}`;
}
function fmtDateShort(d) {
try {
return new Date(d).toLocaleDateString('en-GB', {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
});
} catch { return String(d); }
}
const { getSettingSync } = require('./settingsCache');
function getOrg() {
return {
name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'),
email: process.env.EMAIL_FROM || process.env.EMAIL_USER || '',
url: (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''),
};
}
// ─── Registration confirmation ────────────────────────────────────────────────
/**
* @param {object} reg - full registration from loadRegistrationFull
* @param {{ isNew?: boolean, balance?: number, totalDue?: number, totalPaid?: number }} opts
*/
function buildWARegistration(reg, { isNew = true, balance, totalDue, totalPaid } = {}) {
const org = getOrg();
const eventTitle = reg.event?.title || 'the event';
const eventDate = reg.event?.startDate ? fmtDateShort(reg.event.startDate) : '';
const name = reg.user?.name || 'there';
const heading = isNew ? '🎉 *Registration Confirmed!*' : '✏️ *Registration Updated*';
const intro = isNew
? `You're registered for *${eventTitle}*${eventDate ? ` on ${eventDate}` : ''}.`
: `Your registration for *${eventTitle}* has been updated.`;
const items = (reg.registrationOptions || [])
.map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`)
.join('\n');
const paid = totalPaid ?? (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const due = totalDue ?? 0;
const bal = balance ?? Math.max(due - paid, 0);
const finLine = bal <= 0
? `✅ *Fully paid — you're all set!*`
: `*Balance due:* ${fmtAmount(bal)}\n_Pay at ${org.url} or at the door._`;
return [
heading,
'',
`Hi ${name},`,
'',
intro,
'',
'*Your selections:*',
items || '—',
'',
`*Total:* ${fmtAmount(due)} *Paid:* ${fmtAmount(paid)}`,
finLine,
'',
`_${org.name}_ | ${org.url}`,
].join('\n');
}
// ─── Payment receipt ──────────────────────────────────────────────────────────
function buildWAPayment(payment) {
const org = getOrg();
const eventTitle = payment.registration?.event?.title || payment.event?.title || 'the event';
const name = (payment.registration?.user || payment.user)?.name || 'there';
const amount = fmtAmount(payment.amount);
const reg = payment.registration;
let balLine = '';
if (reg) {
const { computeRegistrationTotalDue } = require('./pricing');
const totalDue = computeRegistrationTotalDue(reg, payment.createdAt || new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const balance = Math.max(totalDue - totalPaid, 0);
balLine = balance <= 0
? `\n✅ *Fully paid!* Your tickets have been sent.`
: `\n*Remaining balance:* ${fmtAmount(balance)}\n_Pay the remainder at ${org.url} or at the door._`;
}
return [
`✅ *Payment Received*`,
'',
`Hi ${name},`,
'',
`We've received your payment of *${amount}* for *${eventTitle}*.`,
balLine,
'',
`_${org.name}_ | ${org.url}`,
].join('\n');
}
// ─── Login notification ───────────────────────────────────────────────────────
function buildWALogin({ name, when, location, userAgent }) {
const org = getOrg();
return [
`🔐 *New Login Detected*`,
'',
`Hi ${name || 'there'},`,
'',
`A new login to your *${org.name}* account was detected.`,
'',
`*Time:* ${when}`,
`*Location:* ${location}`,
`*Device:* ${userAgent}`,
'',
`> _Not you?_ Change your password immediately at ${org.url} or contact ${org.email}.`,
'',
`_If this was you, no action is needed._`,
].join('\n');
}
// ─── Welcome ──────────────────────────────────────────────────────────────────
function buildWAWelcome({ name, events }) {
const org = getOrg();
const hasEvents = Array.isArray(events) && events.length > 0;
const eventsBlock = hasEvents
? [
'*Upcoming events:*',
...events.map(e =>
`- *${e.title}* — ${new Date(e.startDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}`
),
].join('\n')
: 'Keep an eye on our website for upcoming events.';
return [
`🎉 *Welcome to ${org.name}!*`,
'',
`Hi ${name || 'there'},`,
'',
`Your account is set up and ready. Use it to register for events, manage your bookings, and access your tickets.`,
'',
eventsBlock,
'',
`${org.url}`,
].join('\n');
}
// ─── Account closed ───────────────────────────────────────────────────────────
function buildWAAccountClosed({ name, dataDeleted }) {
const org = getOrg();
const detail = dataDeleted
? 'All personal data associated with your account has been permanently deleted.'
: `Your account has been deactivated. To also delete your personal data, contact ${org.email}.`;
return [
`🔒 *Account Closed*`,
'',
`Hi ${name || 'there'},`,
'',
`Your *${org.name}* account has been successfully closed.`,
'',
detail,
'',
`_${org.name}_ | ${org.email}`,
].join('\n');
}
// ─── Ticket delivery caption ──────────────────────────────────────────────────
function buildWATicketCaption({ name, eventTitle, eventDate }) {
return [
`🎟️ *Your tickets for ${eventTitle}*`,
'',
`Hi ${name || 'there'}! Your tickets${eventDate ? ` for *${eventDate}*` : ''} are attached.`,
'Please show this PDF (printed or on your phone) at the event entrance.',
].join('\n');
}
// ─── Refund notification ──────────────────────────────────────────────────────
function buildWARefund(payment) {
const org = getOrg();
const user = payment.user;
const eventTitle = payment.registration?.event?.title || payment.event?.title || 'the event';
const amt = fmtAmount(Math.abs(payment.amount || 0));
const name = user?.name || 'there';
return [
`💸 *Refund Processed*`,
'',
`Hi ${name},`,
'',
`A refund of *${amt}* for *${eventTitle}* has been processed.`,
'',
`Refunds may take a few business days to appear depending on your bank.`,
'',
`_${org.name}_ | ${org.email}`,
].join('\n');
}
// ─── Donation applied to a registration ────────────────────────────────────────
//
// Distinct from buildWAPayment: sent to the REGISTRANT when staff apply someone else's
// donation to their registration — anonymous (no donor name), and never "payment received"
// wording since they didn't pay anything themselves.
function buildWADonationAppliedToRegistrant(payment) {
const org = getOrg();
const reg = payment.registration;
const eventTitle = reg?.event?.title || 'the event';
const name = reg?.user?.name || 'there';
const amount = fmtAmount(payment.amount);
let balLine = '';
if (reg) {
const { computeRegistrationTotalDue } = require('./pricing');
const totalDue = computeRegistrationTotalDue(reg, payment.createdAt || new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const balance = Math.max(totalDue - totalPaid, 0);
balLine = balance <= 0
? `\n✅ *Fully paid!* Your tickets have been sent.`
: `\n*Remaining balance:* ${fmtAmount(balance)}\n_Pay the remainder at ${org.url} or at the door._`;
}
return [
`🎁 *Donation Applied*`,
'',
`Hi ${name},`,
'',
`A donation of *${amount}* was applied to your registration for *${eventTitle}*.`,
balLine,
'',
`_${org.name}_ | ${org.url}`,
].join('\n');
}
module.exports = {
buildWARegistration,
buildWAPayment,
buildWARefund,
buildWADonationAppliedToRegistrant,
buildWALogin,
buildWAWelcome,
buildWAAccountClosed,
buildWATicketCaption,
};
+294
View File
@@ -0,0 +1,294 @@
const axios = require('axios');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const BASE = 'https://api.wawp.net/v2';
// ─── Config cache (DB-backed, env fallback) ───────────────────────────────────
let _configCache = null;
let _configCacheAt = 0;
const CONFIG_TTL_MS = 30_000; // 30 s
/**
* Load WAWP credentials from DB (AppSetting table), falling back to .env.
* Result is cached for 30 seconds so repeated calls don't hit the DB.
*/
async function getConfig() {
const now = Date.now();
if (_configCache && (now - _configCacheAt) < CONFIG_TTL_MS) return _configCache;
try {
const prisma = require('../config/db');
const rows = await prisma.appSetting.findMany({
where: { key: { in: ['WAWP_ACCESS_TOKEN', 'WAWP_INSTANCE_ID'] } },
});
const map = Object.fromEntries(rows.map(r => [r.key, r.value]));
_configCache = {
token: map.WAWP_ACCESS_TOKEN || process.env.WAWP_ACCESS_TOKEN || '',
instanceId: map.WAWP_INSTANCE_ID || process.env.WAWP_INSTANCE_ID || '',
};
} catch {
// DB unavailable — fall back to env vars
_configCache = {
token: process.env.WAWP_ACCESS_TOKEN || '',
instanceId: process.env.WAWP_INSTANCE_ID || '',
};
}
_configCacheAt = now;
return _configCache;
}
/** Save WAWP credentials to DB and invalidate the cache. */
async function setConfig(token, instanceId) {
const prisma = require('../config/db');
await prisma.$transaction([
prisma.appSetting.upsert({
where: { key: 'WAWP_ACCESS_TOKEN' },
update: { value: token },
create: { key: 'WAWP_ACCESS_TOKEN', value: token },
}),
prisma.appSetting.upsert({
where: { key: 'WAWP_INSTANCE_ID' },
update: { value: instanceId },
create: { key: 'WAWP_INSTANCE_ID', value: instanceId },
}),
]);
_configCache = null; // force reload on next getConfig()
}
async function isConfigured() {
const { token, instanceId } = await getConfig();
return !!(token && instanceId);
}
// ─── SA phone normalisation ───────────────────────────────────────────────────
/**
* Normalises any common South African phone format to 27xxxxxxxxx (11 digits).
* Handles: 0821234567 / 082 123 4567 / +27821234567 / +27 82 123 4567
* Returns null when the number cannot be resolved to a valid SA mobile.
*/
function normalizeZAPhone(raw) {
if (!raw) return null;
let digits = String(raw).replace(/\D/g, '');
// Local format: leading 0 + 9 digits (total 10)
if (digits.startsWith('0') && digits.length === 10) {
digits = '27' + digits.slice(1);
}
// Must now be exactly 11 digits starting with 27
if (/^27\d{9}$/.test(digits)) return digits;
return null;
}
/** Returns true when raw is a valid SA mobile number. */
function isValidZAPhone(raw) {
return normalizeZAPhone(raw) !== null;
}
/** Converts a phone number to the WAWP chatId format (e.g. 27821234567@c.us). */
function toChatId(raw) {
const n = normalizeZAPhone(raw);
return n ? `${n}@c.us` : null;
}
// ─── "Session not found" auto-recovery ───────────────────────────────────────
/**
* Returns true when the WAWP error message indicates the session doesn't exist.
*/
function isSessionNotFound(e) {
const msg = (e?.response?.data?.message || e?.message || '').toLowerCase();
return msg.includes('session not found') || msg.includes('instance not found');
}
/**
* If the WAWP API reports "Session not found", clear the stale instance ID
* from the DB so the admin UI drops back to the Session Instance setup step.
*
* @param {Error} e - The error thrown by a WAWP API call
*/
async function handleSessionNotFound(e) {
if (!isSessionNotFound(e)) throw e; // not our problem — re-throw
console.warn('[whatsapp] Session not found — clearing instance ID from DB...');
const { token, instanceId } = await getConfig();
// Try to delete the stale session on WAWP (may fail — that's okay)
try {
await axios.post(`${BASE}/session/delete`, { access_token: token, instance_id: instanceId });
} catch (delErr) {
console.warn('[whatsapp] Delete stale session failed (ignored):', delErr?.response?.data?.message || delErr.message);
}
// Clear the instance ID from DB so the frontend goes back to Step 2
await setConfig(token, '');
console.info('[whatsapp] Instance ID cleared — admin must set up a new session instance.');
throw new Error('SESSION_NOT_FOUND');
}
// ─── Session management ───────────────────────────────────────────────────────
async function getStatus() {
const { token, instanceId } = await getConfig();
try {
const res = await axios.post(`${BASE}/session/info`, { access_token: token, instance_id: instanceId });
return res.data;
} catch (e) {
await handleSessionNotFound(e);
}
}
async function startSession() {
const { token, instanceId } = await getConfig();
try {
const res = await axios.post(`${BASE}/session/start`, { access_token: token, instance_id: instanceId });
return res.data;
} catch (e) {
await handleSessionNotFound(e);
}
}
async function restartSession() {
const { token, instanceId } = await getConfig();
try {
const res = await axios.post(`${BASE}/session/restart`, { access_token: token, instance_id: instanceId });
return res.data;
} catch (e) {
await handleSessionNotFound(e);
}
}
async function logoutSession() {
const { token, instanceId } = await getConfig();
const res = await axios.post(`${BASE}/session/logout`, { access_token: token, instance_id: instanceId });
return res.data;
}
async function createInstance(name) {
const { token } = await getConfig();
const label = name || `hope-events-${Date.now()}`;
const res = await axios.post(`${BASE}/session/create`, { access_token: token, name: label });
const newInstanceId = res.data?.instance_id || res.data?.id;
if (!newInstanceId) throw new Error('Create instance returned no instance_id');
await setConfig(token, newInstanceId);
return { ...res.data, instance_id: newInstanceId };
}
async function deleteInstance() {
const { token, instanceId } = await getConfig();
if (!instanceId) throw new Error('No instance configured');
const res = await axios.post(`${BASE}/session/delete`, { access_token: token, instance_id: instanceId });
// Clear instance_id from DB after deletion
await setConfig(token, '');
return res.data;
}
async function getQr() {
const { token, instanceId } = await getConfig();
try {
const res = await axios.post(`${BASE}/auth/qr-image`, { access_token: token, instance_id: instanceId });
return res.data; // { qr: 'data:image/png;base64,...' }
} catch (e) {
await handleSessionNotFound(e);
}
}
async function requestPairingCode(phoneNumber) {
const { token, instanceId } = await getConfig();
const normalized = normalizeZAPhone(phoneNumber);
if (!normalized) throw new Error('Invalid South African phone number');
try {
const res = await axios.post(`${BASE}/auth/request-code`, {
access_token: token,
instance_id: instanceId,
phone_number: normalized,
});
return res.data; // { code: 'ABCD-1234' }
} catch (e) {
await handleSessionNotFound(e);
}
}
// ─── Messaging ────────────────────────────────────────────────────────────────
async function sendText(toPhone, message) {
if (!(await isConfigured())) return;
const chatId = toChatId(toPhone);
if (!chatId) { console.warn('[whatsapp] Invalid phone, skipping text:', toPhone); return; }
const { token, instanceId } = await getConfig();
await axios.post(`${BASE}/send/text`, {
access_token: token,
instance_id: instanceId,
chatId,
message,
});
}
/**
* Copies a local PDF to a temporary public URL, sends it via WAWP,
* then schedules the temp file for deletion after 5 minutes.
*/
async function sendPdf(toPhone, localPdfPath, filename, caption) {
if (!(await isConfigured())) return;
const chatId = toChatId(toPhone);
if (!chatId) { console.warn('[whatsapp] Invalid phone, skipping PDF:', toPhone); return; }
const tempDir = path.join(__dirname, '../../public/uploads/tickets-temp');
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
const tempName = `${uuidv4()}.pdf`;
const tempPath = path.join(tempDir, tempName);
fs.copyFileSync(localPdfPath, tempPath);
const backendUrl = (process.env.BACKEND_URL || '').replace(/\/$/, '');
const isLocalhost = !backendUrl || /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(backendUrl);
if (isLocalhost) {
try { fs.unlinkSync(tempPath); } catch {}
throw new Error(
`WhatsApp PDF delivery requires a publicly accessible backend URL. ` +
`BACKEND_URL is currently "${backendUrl || '(not set)'}". ` +
`Set BACKEND_URL to your public backend URL (e.g. https://api.yourdomain.com) in your .env file.`
);
}
const pdfUrl = `${backendUrl}/uploads/tickets-temp/${tempName}`;
const { token, instanceId } = await getConfig();
await axios.post(`${BASE}/send/pdf`, {
access_token: token,
instance_id: instanceId,
chatId,
file: {
url: pdfUrl,
filename: filename || 'tickets.pdf',
mimetype: 'application/pdf',
},
caption: caption || '',
});
// Clean up after 5 minutes — WAWP will have fetched the file by then
setTimeout(() => { try { fs.unlinkSync(tempPath); } catch {} }, 5 * 60 * 1000);
}
module.exports = {
getConfig,
setConfig,
normalizeZAPhone,
isValidZAPhone,
toChatId,
isConfigured,
getStatus,
startSession,
restartSession,
logoutSession,
createInstance,
deleteInstance,
getQr,
requestPairingCode,
sendText,
sendPdf,
};