Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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}‌ ‌ ‌ ‌ </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} •
|
||||
<a href="mailto:${org.email}" style="color:#94a3b8;text-decoration:underline">${org.email}</a> •
|
||||
<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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 || '—'} • 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 || '—'} • 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} <${payerEmail}></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'} • 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 & Donations</p>
|
||||
${payTableHtml}
|
||||
|
||||
<p style="font-size:12px;color:#94a3b8;margin:24px 0 0 0;font-family:${ff}">
|
||||
Event starts: ${fmtDate(ev.startDate)} • 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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user