Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const generateToken = (userId, role, tokenVersion = 0) => {
|
||||
return jwt.sign(
|
||||
{ id: userId, role, tokenVersion },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '30d' }
|
||||
);
|
||||
};
|
||||
|
||||
const hashPassword = async (password) => {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
return await bcrypt.hash(password, salt);
|
||||
};
|
||||
|
||||
const comparePassword = async (enteredPassword, storedPassword) => {
|
||||
return await bcrypt.compare(enteredPassword, storedPassword);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
generateToken,
|
||||
hashPassword,
|
||||
comparePassword,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
module.exports = prisma;
|
||||
@@ -0,0 +1,52 @@
|
||||
const { addJob } = require('../utils/scheduledEmails');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
// @desc Schedule multiple automation emails for an event
|
||||
// @route POST /api/automations/schedule
|
||||
// @access Private/Supervisor or Admin
|
||||
async function scheduleAutomations(req, res) {
|
||||
try {
|
||||
const { eventId, jobs } = req.body || {};
|
||||
if (!eventId || typeof eventId !== 'string') {
|
||||
return res.status(400).json({ message: 'eventId is required' });
|
||||
}
|
||||
if (!Array.isArray(jobs) || jobs.length === 0) {
|
||||
return res.status(400).json({ message: 'jobs must be a non-empty array' });
|
||||
}
|
||||
|
||||
const created = [];
|
||||
for (const j of jobs) {
|
||||
if (!j || !j.scheduledAt || !j.subject || !(j.html || j.text)) continue;
|
||||
const when = new Date(j.scheduledAt);
|
||||
if (isNaN(when.getTime())) continue;
|
||||
const payload = {
|
||||
subject: String(j.subject),
|
||||
html: j.html ? String(j.html) : undefined,
|
||||
text: (!j.html && j.text) ? String(j.text) : (j.text ? String(j.text) : undefined),
|
||||
template: 'custom',
|
||||
filter: { status: undefined, attendeeIds: undefined },
|
||||
};
|
||||
if (j.promoEventId && typeof j.promoEventId === 'string') {
|
||||
payload.promoEventId = j.promoEventId;
|
||||
}
|
||||
const rec = addJob({
|
||||
id: uuidv4(),
|
||||
eventId,
|
||||
createdById: req.user?.id || null,
|
||||
scheduledAt: when.toISOString(),
|
||||
payload,
|
||||
});
|
||||
created.push(rec);
|
||||
}
|
||||
|
||||
if (created.length === 0) {
|
||||
return res.status(400).json({ message: 'No valid jobs to schedule' });
|
||||
}
|
||||
|
||||
return res.status(201).json({ message: `Scheduled ${created.length} automation job(s)`, jobs: created });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error?.message || String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { scheduleAutomations };
|
||||
@@ -0,0 +1,44 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const BANNER_FILE = path.join(__dirname, '../../data/banner.json');
|
||||
|
||||
function readBanner() {
|
||||
try {
|
||||
const raw = fs.readFileSync(BANNER_FILE, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return { message: '', type: 'info', liveFrom: null, liveTill: null };
|
||||
}
|
||||
}
|
||||
|
||||
function writeBanner(data) {
|
||||
fs.writeFileSync(BANNER_FILE, JSON.stringify(data, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
// @desc Get the current banner
|
||||
// @route GET /api/banner
|
||||
// @access Public
|
||||
const getBanner = (req, res) => {
|
||||
res.json(readBanner());
|
||||
};
|
||||
|
||||
// @desc Set the banner
|
||||
// @route POST /api/banner
|
||||
// @access Supervisor / Admin
|
||||
const setBanner = (req, res) => {
|
||||
const { message, type, liveFrom, liveTill } = req.body;
|
||||
|
||||
const allowed = ['info', 'warning', 'success', 'danger'];
|
||||
const banner = {
|
||||
message: typeof message === 'string' ? message.trim() : '',
|
||||
type: allowed.includes(type) ? type : 'info',
|
||||
liveFrom: liveFrom || null,
|
||||
liveTill: liveTill || null,
|
||||
};
|
||||
|
||||
writeBanner(banner);
|
||||
res.json(banner);
|
||||
};
|
||||
|
||||
module.exports = { getBanner, setBanner };
|
||||
@@ -0,0 +1,215 @@
|
||||
const prisma = require('../config/db');
|
||||
|
||||
// Escape user-controlled strings before inserting them into HTML
|
||||
function escapeHtml(str) {
|
||||
return String(str || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Utilities shared with attendees emailing
|
||||
function fmtDate(d) {
|
||||
try { return new Date(d).toLocaleString(); } catch { return String(d); }
|
||||
}
|
||||
|
||||
function getFrontendBaseUrl() {
|
||||
const base = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||
return String(base).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function buildEventContext(event) {
|
||||
if (!event) return { eventTitle: '', eventStart: '', eventLink: '', eventLinkHtml: '' };
|
||||
const eventTitle = event.title || '';
|
||||
const eventStart = event.startDate ? fmtDate(event.startDate) : '';
|
||||
const eventLink = `${getFrontendBaseUrl()}/events/${encodeURIComponent(event.id)}`;
|
||||
const eventLinkHtml = `<a href="${eventLink}">${eventLink}</a>`;
|
||||
return { eventTitle, eventStart, eventLink, eventLinkHtml };
|
||||
}
|
||||
|
||||
function replacePlaceholders(str, ctx) {
|
||||
if (!str) return str;
|
||||
return String(str)
|
||||
.replace(/\{\{\s*name\s*\}\}/g, ctx.name || '')
|
||||
.replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '')
|
||||
.replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '')
|
||||
.replace(/\{\{\s*event\.(link|url)\s*\}\}/g, (ctx.eventLinkHtml || ctx.eventLink || ''));
|
||||
}
|
||||
|
||||
function parseFreeformEmails(lines) {
|
||||
// Supports formats:
|
||||
// - email@example.com
|
||||
// - Name <email@example.com>
|
||||
// - "Name" <email@example.com>
|
||||
const recipients = [];
|
||||
const input = Array.isArray(lines) ? lines : String(lines || '').split(/\r?\n/);
|
||||
for (const raw of input) {
|
||||
const s = String(raw || '').trim();
|
||||
if (!s) continue;
|
||||
let name = '';
|
||||
let email = '';
|
||||
const m = s.match(/^(.*?)<\s*([^>\s]+@[^>\s]+)\s*>\s*$/);
|
||||
if (m) {
|
||||
name = m[1].trim().replace(/^"|"$/g, '').trim();
|
||||
email = m[2].trim();
|
||||
} else {
|
||||
// If it just looks like an email, accept it
|
||||
const em = s.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/) ? s : '';
|
||||
if (em) email = em; else continue;
|
||||
}
|
||||
recipients.push({ email, name });
|
||||
}
|
||||
return recipients;
|
||||
}
|
||||
|
||||
// @desc Preview broadcast recipients and sample
|
||||
// @route POST /api/broadcasts/preview
|
||||
// @access Private/Supervisor or Admin
|
||||
const previewBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { userIds, emails, eventId } = req.body || {};
|
||||
|
||||
// Resolve users
|
||||
const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : [];
|
||||
let users = [];
|
||||
if (ids.length) {
|
||||
users = await prisma.user.findMany({ where: { id: { in: ids } }, select: { id: true, name: true, email: true } });
|
||||
}
|
||||
|
||||
// Parse freeform emails
|
||||
const extra = parseFreeformEmails(emails);
|
||||
|
||||
// Merge and de-duplicate by email
|
||||
const map = new Map();
|
||||
for (const u of users) {
|
||||
const email = String(u.email || '').trim();
|
||||
if (!email) continue;
|
||||
if (!map.has(email)) map.set(email, { email, name: u.name || '' });
|
||||
}
|
||||
for (const r of extra) {
|
||||
const email = String(r.email || '').trim();
|
||||
if (!email) continue;
|
||||
if (!map.has(email)) map.set(email, { email, name: r.name || '' });
|
||||
}
|
||||
const recipients = Array.from(map.values());
|
||||
|
||||
// Optionally resolve event info for link/title placeholder
|
||||
let event = null;
|
||||
if (eventId && typeof eventId === 'string') {
|
||||
event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
}
|
||||
const eventCtx = buildEventContext(event);
|
||||
|
||||
return res.json({ matched: recipients.length, recipients: recipients.slice(0, 20), event: event ? { id: event.id, title: event.title } : null, placeholders: ['{{name}}','{{event.title}}','{{event.start}}','{{event.link}}'] });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Send broadcast now
|
||||
// @route POST /api/broadcasts/send
|
||||
// @access Private/Supervisor or Admin
|
||||
const sendBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { subject, html, text, userIds, emails, eventId } = req.body || {};
|
||||
if (!subject || !(html || text)) {
|
||||
return res.status(400).json({ message: 'Subject and message (html or text) are required' });
|
||||
}
|
||||
|
||||
// Resolve recipients similar to preview
|
||||
const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : [];
|
||||
let users = [];
|
||||
if (ids.length) {
|
||||
users = await prisma.user.findMany({ where: { id: { in: ids } }, select: { id: true, name: true, email: true } });
|
||||
}
|
||||
const extra = parseFreeformEmails(emails);
|
||||
|
||||
const map = new Map();
|
||||
for (const u of users) {
|
||||
const email = String(u.email || '').trim();
|
||||
if (!email) continue;
|
||||
if (!map.has(email)) map.set(email, { email, name: u.name || '' });
|
||||
}
|
||||
for (const r of extra) {
|
||||
const email = String(r.email || '').trim();
|
||||
if (!email) continue;
|
||||
if (!map.has(email)) map.set(email, { email, name: r.name || '' });
|
||||
}
|
||||
const recipients = Array.from(map.values());
|
||||
if (recipients.length === 0) {
|
||||
return res.status(400).json({ message: 'No valid recipients' });
|
||||
}
|
||||
|
||||
// Resolve event
|
||||
let event = null;
|
||||
if (eventId && typeof eventId === 'string') {
|
||||
event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
}
|
||||
const eventCtx = buildEventContext(event);
|
||||
|
||||
const { sendMail } = require('../utils/email');
|
||||
|
||||
// Send all emails in parallel instead of sequentially — critical for large recipient lists
|
||||
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
||||
const ctxBase = {
|
||||
name: rcpt.name || '',
|
||||
eventTitle: eventCtx.eventTitle,
|
||||
eventStart: eventCtx.eventStart,
|
||||
eventLink: eventCtx.eventLink,
|
||||
};
|
||||
const ctxForHtml = html ? {
|
||||
name: escapeHtml(rcpt.name || ''),
|
||||
eventTitle: escapeHtml(eventCtx.eventTitle),
|
||||
eventStart: escapeHtml(eventCtx.eventStart),
|
||||
eventLink: escapeHtml(eventCtx.eventLink),
|
||||
eventLinkHtml: eventCtx.eventLinkHtml,
|
||||
} : ctxBase;
|
||||
const finalSubject = replacePlaceholders(subject, ctxBase);
|
||||
const finalHtml = html ? replacePlaceholders(html, ctxForHtml) : undefined;
|
||||
const finalText = (!html ? replacePlaceholders(text || '', ctxBase) : undefined);
|
||||
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
|
||||
}));
|
||||
|
||||
const sent = results.filter(r => r.status === 'fulfilled').length;
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
try { console.warn('[broadcast] failed to send to', recipients[i]?.email, r.reason?.message || r.reason); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({ matched: recipients.length, sent });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Schedule a broadcast
|
||||
// @route POST /api/broadcasts/schedule
|
||||
// @access Private/Supervisor or Admin
|
||||
const scheduleBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { scheduledAt, subject, html, text, userIds, emails, eventId } = req.body || {};
|
||||
if (!scheduledAt) return res.status(400).json({ message: 'scheduledAt is required' });
|
||||
const when = new Date(scheduledAt);
|
||||
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
|
||||
if (!subject || !(html || text)) return res.status(400).json({ message: 'Subject and message (html or text) are required' });
|
||||
|
||||
const payload = { subject, html, text, userIds, emails, eventId };
|
||||
|
||||
const { addJob } = require('../utils/scheduledEmails');
|
||||
const created = addJob({
|
||||
broadcast: true,
|
||||
scheduledAt: when.toISOString(),
|
||||
createdById: req.user?.id || null,
|
||||
payload,
|
||||
});
|
||||
|
||||
return res.status(201).json({ message: 'Broadcast scheduled', job: created });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { previewBroadcast, sendBroadcast, scheduleBroadcast };
|
||||
@@ -0,0 +1,186 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { ALL_METHODS, assertEventOpen, computeEventFinancials } = require('../utils/cashupUtils');
|
||||
|
||||
// @desc Cashup preview for an event: live expected/actual numbers, costs, donations-to-profit, and history
|
||||
// @route GET /api/cashups/event/:eventId
|
||||
// @access Private/Supervisor
|
||||
const getEventCashup = async (req, res) => {
|
||||
try {
|
||||
const financials = await computeEventFinancials(req.params.eventId);
|
||||
res.json(financials);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Save in-progress reconciliation entries without closing the event
|
||||
// @route PUT /api/cashups/event/:eventId/draft
|
||||
// @access Private/Admin
|
||||
const saveEventCashupDraft = async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { lines, notes } = req.body;
|
||||
|
||||
await assertEventOpen(eventId, res);
|
||||
|
||||
await prisma.event.update({
|
||||
where: { id: eventId },
|
||||
data: { cashupDraft: { lines: Array.isArray(lines) ? lines : [], notes: notes || null, savedAt: new Date().toISOString() } }
|
||||
});
|
||||
|
||||
res.json({ message: 'Draft saved' });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Close an event, optionally with a full per-method cashup
|
||||
// @route POST /api/cashups/event/:eventId/close
|
||||
// @access Private/Admin
|
||||
const closeEvent = async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { lines, notes } = req.body;
|
||||
|
||||
await assertEventOpen(eventId, res);
|
||||
|
||||
const financials = await computeEventFinancials(eventId);
|
||||
const isFullCashup = Array.isArray(lines) && lines.length > 0;
|
||||
|
||||
const cashupLines = isFullCashup
|
||||
? lines
|
||||
.filter(l => l && ALL_METHODS.includes(l.method))
|
||||
.map(l => {
|
||||
const expected = financials.expectedCashByMethod[l.method] || 0;
|
||||
const denominations = l.method === 'cash' && Array.isArray(l.denominations)
|
||||
? l.denominations
|
||||
.map(d => ({ value: parseFloat(d.value), count: parseInt(d.count, 10) || 0 }))
|
||||
.filter(d => d.value > 0 && d.count > 0)
|
||||
: [];
|
||||
const actual = denominations.length > 0
|
||||
? denominations.reduce((sum, d) => sum + d.value * d.count, 0)
|
||||
: (l.actualAmount !== undefined && l.actualAmount !== null && l.actualAmount !== '' ? parseFloat(l.actualAmount) : null);
|
||||
return {
|
||||
id: uuidv4(),
|
||||
method: l.method,
|
||||
expectedAmount: expected,
|
||||
actualAmount: actual,
|
||||
variance: actual !== null ? actual - expected : null,
|
||||
notes: l.notes || null,
|
||||
denominations: denominations.length > 0 ? { create: denominations } : undefined
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const totalActualRevenue = isFullCashup
|
||||
? cashupLines.reduce((sum, l) => sum + (l.actualAmount !== null ? l.actualAmount : 0), 0)
|
||||
: null;
|
||||
const totalExpectedRevenue = ALL_METHODS.reduce((sum, m) => sum + financials.expectedCashByMethod[m], 0);
|
||||
|
||||
const cashup = await prisma.eventCashup.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
eventId,
|
||||
action: isFullCashup ? 'closed' : 'quick_closed',
|
||||
unallocatedDonationsTotal: financials.unallocatedDonationsTotal,
|
||||
totalCosts: financials.totalCosts,
|
||||
totalExpectedRevenue,
|
||||
totalActualRevenue,
|
||||
notes: notes || null,
|
||||
performedById: req.user.id,
|
||||
lines: { create: cashupLines }
|
||||
},
|
||||
include: { lines: { include: { denominations: true } }, performedBy: { select: { id: true, name: true, email: true } } }
|
||||
});
|
||||
|
||||
await prisma.event.update({
|
||||
where: { id: eventId },
|
||||
data: { cashupStatus: 'closed', cashupDraft: null, closedAt: new Date(), closedById: req.user.id }
|
||||
});
|
||||
|
||||
res.status(201).json(cashup);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Reopen a closed event (admin only)
|
||||
// @route POST /api/cashups/event/:eventId/reopen
|
||||
// @access Private/Admin
|
||||
const reopenEvent = async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { notes } = req.body;
|
||||
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
if (!event) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
if (event.cashupStatus !== 'closed') {
|
||||
res.status(400);
|
||||
throw new Error('Event is not closed');
|
||||
}
|
||||
|
||||
const auditRow = await prisma.eventCashup.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
eventId,
|
||||
action: 'reopened',
|
||||
notes: notes || null,
|
||||
performedById: req.user.id
|
||||
},
|
||||
include: { performedBy: { select: { id: true, name: true, email: true } } }
|
||||
});
|
||||
|
||||
await prisma.event.update({
|
||||
where: { id: eventId },
|
||||
data: { cashupStatus: 'open', reopenedAt: new Date(), reopenedById: req.user.id }
|
||||
});
|
||||
|
||||
res.status(201).json(auditRow);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Flat audit log of every close/quick-close/reopen, optionally filtered
|
||||
// @route GET /api/cashups/audit
|
||||
// @access Private/Supervisor
|
||||
const getCashupAudit = async (req, res) => {
|
||||
try {
|
||||
const { eventId, from, to } = req.query;
|
||||
|
||||
const where = {};
|
||||
if (eventId) where.eventId = eventId;
|
||||
if (from || to) {
|
||||
where.createdAt = {};
|
||||
if (from) where.createdAt.gte = new Date(from);
|
||||
if (to) where.createdAt.lte = new Date(to);
|
||||
}
|
||||
|
||||
const rows = await prisma.eventCashup.findMany({
|
||||
where,
|
||||
include: {
|
||||
event: { select: { id: true, title: true } },
|
||||
performedBy: { select: { id: true, name: true, email: true } },
|
||||
lines: { include: { denominations: true } }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getEventCashup,
|
||||
saveEventCashupDraft,
|
||||
closeEvent,
|
||||
reopenEvent,
|
||||
getCashupAudit
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { assertEventOpen, ALL_METHODS } = require('../utils/cashupUtils');
|
||||
|
||||
function normalizePaidFromMethod(value) {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null || value === '') return null;
|
||||
if (!ALL_METHODS.includes(value)) {
|
||||
throw new Error("paidFromMethod must be one of 'cash', 'card', 'eft', 'other', or null");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// @desc List costs for an event
|
||||
// @route GET /api/events/:eventId/costs
|
||||
// @access Private/Supervisor
|
||||
const getEventCosts = async (req, res) => {
|
||||
try {
|
||||
const costs = await prisma.eventCost.findMany({
|
||||
where: { eventId: req.params.eventId },
|
||||
include: { eventOption: { select: { id: true, name: true } } },
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
res.json(costs);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Create a cost for an event
|
||||
// @route POST /api/events/:eventId/costs
|
||||
// @access Private/Supervisor
|
||||
const createEventCost = async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { label, costType, amount, eventOptionId, notes, paidFromMethod } = req.body;
|
||||
|
||||
await assertEventOpen(eventId, res);
|
||||
|
||||
if (!label || !String(label).trim()) {
|
||||
res.status(400);
|
||||
throw new Error('Label is required');
|
||||
}
|
||||
if (costType !== 'once_off' && costType !== 'per_item') {
|
||||
res.status(400);
|
||||
throw new Error("costType must be 'once_off' or 'per_item'");
|
||||
}
|
||||
const amt = parseFloat(amount);
|
||||
if (!(amt >= 0)) {
|
||||
res.status(400);
|
||||
throw new Error('Amount must be a non-negative number');
|
||||
}
|
||||
if (costType === 'per_item' && !eventOptionId) {
|
||||
res.status(400);
|
||||
throw new Error('eventOptionId is required for per-item costs');
|
||||
}
|
||||
if (costType === 'per_item') {
|
||||
const option = await prisma.eventOption.findUnique({ where: { id: eventOptionId } });
|
||||
if (!option || option.eventId !== eventId) {
|
||||
res.status(404);
|
||||
throw new Error('Ticket type not found for this event');
|
||||
}
|
||||
}
|
||||
|
||||
const cost = await prisma.eventCost.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
eventId,
|
||||
label: String(label).trim(),
|
||||
costType,
|
||||
amount: amt,
|
||||
eventOptionId: costType === 'per_item' ? eventOptionId : null,
|
||||
paidFromMethod: normalizePaidFromMethod(paidFromMethod) ?? null,
|
||||
notes: notes || null
|
||||
},
|
||||
include: { eventOption: { select: { id: true, name: true } } }
|
||||
});
|
||||
|
||||
res.status(201).json(cost);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Update a cost
|
||||
// @route PUT /api/costs/:id
|
||||
// @access Private/Supervisor
|
||||
const updateEventCost = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.eventCost.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) {
|
||||
res.status(404);
|
||||
throw new Error('Cost not found');
|
||||
}
|
||||
|
||||
await assertEventOpen(existing.eventId, res);
|
||||
|
||||
const { label, costType, amount, eventOptionId, notes, paidFromMethod } = req.body;
|
||||
const nextCostType = costType !== undefined ? costType : existing.costType;
|
||||
if (nextCostType !== 'once_off' && nextCostType !== 'per_item') {
|
||||
res.status(400);
|
||||
throw new Error("costType must be 'once_off' or 'per_item'");
|
||||
}
|
||||
|
||||
const nextEventOptionId = nextCostType === 'per_item'
|
||||
? (eventOptionId !== undefined ? eventOptionId : existing.eventOptionId)
|
||||
: null;
|
||||
if (nextCostType === 'per_item') {
|
||||
if (!nextEventOptionId) {
|
||||
res.status(400);
|
||||
throw new Error('eventOptionId is required for per-item costs');
|
||||
}
|
||||
const option = await prisma.eventOption.findUnique({ where: { id: nextEventOptionId } });
|
||||
if (!option || option.eventId !== existing.eventId) {
|
||||
res.status(404);
|
||||
throw new Error('Ticket type not found for this event');
|
||||
}
|
||||
}
|
||||
|
||||
let nextAmount = existing.amount;
|
||||
if (amount !== undefined) {
|
||||
const amt = parseFloat(amount);
|
||||
if (!(amt >= 0)) {
|
||||
res.status(400);
|
||||
throw new Error('Amount must be a non-negative number');
|
||||
}
|
||||
nextAmount = amt;
|
||||
}
|
||||
|
||||
const nextPaidFromMethod = normalizePaidFromMethod(paidFromMethod);
|
||||
|
||||
const cost = await prisma.eventCost.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
label: label !== undefined ? String(label).trim() : existing.label,
|
||||
costType: nextCostType,
|
||||
amount: nextAmount,
|
||||
eventOptionId: nextEventOptionId,
|
||||
paidFromMethod: nextPaidFromMethod !== undefined ? nextPaidFromMethod : existing.paidFromMethod,
|
||||
notes: notes !== undefined ? notes : existing.notes
|
||||
},
|
||||
include: { eventOption: { select: { id: true, name: true } } }
|
||||
});
|
||||
|
||||
res.json(cost);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Delete a cost
|
||||
// @route DELETE /api/costs/:id
|
||||
// @access Private/Supervisor
|
||||
const deleteEventCost = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.eventCost.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) {
|
||||
res.status(404);
|
||||
throw new Error('Cost not found');
|
||||
}
|
||||
|
||||
await assertEventOpen(existing.eventId, res);
|
||||
|
||||
await prisma.eventCost.delete({ where: { id: req.params.id } });
|
||||
res.json({ message: 'Cost deleted' });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getEventCosts,
|
||||
createEventCost,
|
||||
updateEventCost,
|
||||
deleteEventCost
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
const prisma = require('../config/db');
|
||||
|
||||
// @desc List submitted form responses with optional filters
|
||||
// @route GET /api/forms/responses
|
||||
// @access Private/Staff (admin, supervisor, staff)
|
||||
const listFormResponses = async (req, res) => {
|
||||
try {
|
||||
const { eventId, userId, registrationId, limit, cursor } = req.query;
|
||||
|
||||
// Build where clause
|
||||
const where = {};
|
||||
if (registrationId) {
|
||||
where.registrationId = String(registrationId);
|
||||
}
|
||||
// For eventId/userId we filter through the related registration
|
||||
const registrationFilter = {};
|
||||
if (eventId) registrationFilter.eventId = String(eventId);
|
||||
if (userId) registrationFilter.userId = String(userId);
|
||||
if (Object.keys(registrationFilter).length > 0) {
|
||||
// Prisma relation filter requires `is` wrapper
|
||||
where.registration = { is: registrationFilter };
|
||||
}
|
||||
|
||||
// Basic pagination (optional)
|
||||
const take = Math.min(Math.max(parseInt(limit || '50', 10) || 50, 1), 200);
|
||||
const cursorClause = cursor ? { id: String(cursor) } : undefined;
|
||||
|
||||
const responses = await prisma.formResponse.findMany({
|
||||
where,
|
||||
include: {
|
||||
registration: {
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
event: { select: { id: true, title: true } },
|
||||
}
|
||||
},
|
||||
answers: { include: { field: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take,
|
||||
...(cursorClause ? { skip: 1, cursor: cursorClause } : {}),
|
||||
});
|
||||
|
||||
const nextCursor = responses.length === take ? responses[responses.length - 1].id : null;
|
||||
|
||||
res.json({ items: responses, nextCursor });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { listFormResponses };
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,275 @@
|
||||
const PDFDocument = require('pdfkit');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
// Utility: draw a table
|
||||
function drawTable(doc, startX, startY, colWidths, rows, header) {
|
||||
let y = startY;
|
||||
doc.font('Helvetica-Bold');
|
||||
if (header && header.length) {
|
||||
let x = startX;
|
||||
header.forEach((h, i) => {
|
||||
const w = colWidths[i] || 80;
|
||||
doc.rect(x, y, w, 20).stroke();
|
||||
doc.text(String(h || ''), x + 4, y + 6, { width: w - 8 });
|
||||
x += w;
|
||||
});
|
||||
y += 20;
|
||||
}
|
||||
doc.font('Helvetica');
|
||||
rows.forEach((row) => {
|
||||
let x = startX;
|
||||
row.forEach((cell, i) => {
|
||||
const w = colWidths[i] || 80;
|
||||
const h = 18;
|
||||
doc.rect(x, y, w, h).stroke();
|
||||
doc.text(String(cell ?? ''), x + 4, y + 4, { width: w - 8 });
|
||||
x += w;
|
||||
});
|
||||
y += 18;
|
||||
// New page if overflow
|
||||
if (y > doc.page.height - 40) {
|
||||
doc.addPage();
|
||||
y = 20;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function a4Doc(orientation = 'portrait') {
|
||||
return new PDFDocument({ size: 'A4', margin: 20, layout: orientation === 'landscape' ? 'landscape' : 'portrait' });
|
||||
}
|
||||
|
||||
// POST /api/reports/pdf
|
||||
// body: { title: string, kind: 'table'|'layered', table?: { columns: string[], rows: string[][] }, layered?: { header?: string, sections: { title: string, items: string[] }[] } }
|
||||
const generatePdf = async (req, res) => {
|
||||
try {
|
||||
const { title, kind, table, layered, orientation } = req.body || {};
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
const filename = `${(title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
|
||||
const doc = a4Doc(orientation === 'landscape' ? 'landscape' : 'portrait');
|
||||
doc.pipe(res);
|
||||
|
||||
// Title
|
||||
doc.font('Helvetica-Bold').fontSize(16).text(title || 'Report', { align: 'left' });
|
||||
doc.moveDown(0.5);
|
||||
|
||||
if (kind === 'table' && table && Array.isArray(table.rows)) {
|
||||
const columns = Array.isArray(table.columns) ? table.columns : [];
|
||||
const colCount = columns.length || (table.rows[0] ? table.rows[0].length : 1);
|
||||
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||
// Slightly wider first column to mimic site tables
|
||||
const baseWidth = Math.floor(pageWidth / Math.max(1, colCount));
|
||||
const colWidths = new Array(colCount).fill(baseWidth);
|
||||
if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2);
|
||||
|
||||
// Draw header band
|
||||
if (columns.length) {
|
||||
let x = doc.page.margins.left;
|
||||
const y = doc.y;
|
||||
doc.save();
|
||||
doc.rect(x, y, pageWidth, 22).fill('#f3f4f6');
|
||||
doc.fillColor('#111827').font('Helvetica-Bold').fontSize(11);
|
||||
columns.forEach((h, i) => {
|
||||
const w = colWidths[i] || baseWidth;
|
||||
doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 });
|
||||
x += w;
|
||||
});
|
||||
doc.restore();
|
||||
doc.moveDown(1.6);
|
||||
}
|
||||
|
||||
// Zebra rows
|
||||
const rows = table.rows;
|
||||
rows.forEach((row, idx) => {
|
||||
const rowY = doc.y;
|
||||
const rowH = 18;
|
||||
const bg = idx % 2 === 0 ? '#ffffff' : '#f9fafb';
|
||||
doc.save();
|
||||
doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore();
|
||||
let x = doc.page.margins.left;
|
||||
row.forEach((cell, i) => {
|
||||
const w = colWidths[i] || baseWidth;
|
||||
// Cell text
|
||||
doc.fillColor('#111827').font('Helvetica').fontSize(10).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 });
|
||||
// Vertical separators similar to table borders
|
||||
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke();
|
||||
x += w;
|
||||
});
|
||||
// Right border
|
||||
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left + pageWidth, rowY - 2).lineTo(doc.page.margins.left + pageWidth, rowY + rowH + 2).stroke();
|
||||
doc.moveDown(1.1);
|
||||
if (doc.y > doc.page.height - 40) {
|
||||
doc.addPage();
|
||||
}
|
||||
});
|
||||
// Bottom border
|
||||
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke();
|
||||
|
||||
} else if (kind === 'layered' && layered && Array.isArray(layered.sections)) {
|
||||
if (layered.header) {
|
||||
doc.font('Helvetica-Bold').fontSize(13).text(layered.header);
|
||||
doc.moveDown(0.3);
|
||||
}
|
||||
doc.font('Helvetica').fontSize(11);
|
||||
for (const section of layered.sections) {
|
||||
doc.fillColor('#111827').font('Helvetica-Bold').text(String(section.title || ''), { continued: false });
|
||||
doc.moveDown(0.15);
|
||||
doc.font('Helvetica').fontSize(10);
|
||||
if (Array.isArray(section.items) && section.items.length) {
|
||||
for (const item of section.items) {
|
||||
// Bullet dot
|
||||
doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill('#374151').stroke();
|
||||
doc.fillColor('#111827');
|
||||
doc.text(' ' + String(item || ''), doc.page.margins.left + 8, doc.y, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 8 });
|
||||
doc.moveDown(0.2);
|
||||
}
|
||||
} else {
|
||||
doc.text('No items');
|
||||
}
|
||||
doc.moveDown(0.5);
|
||||
if (doc.y > doc.page.height - 60) doc.addPage();
|
||||
}
|
||||
} else {
|
||||
doc.font('Helvetica').text('No content');
|
||||
}
|
||||
|
||||
doc.end();
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST /api/reports/email
|
||||
// body: { title, kind, table?, layered?, subject?, body? }
|
||||
const emailPdf = async (req, res) => {
|
||||
try {
|
||||
const { title, kind, table, layered, subject, body, orientation } = req.body || {};
|
||||
const user = req.user;
|
||||
if (!user || !user.email) {
|
||||
res.status(400);
|
||||
throw new Error('User email not available');
|
||||
}
|
||||
|
||||
// Ensure temp dir
|
||||
const tempDir = path.join(__dirname, '..', '..', 'temp');
|
||||
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
|
||||
const filePath = path.join(tempDir, `${(title || 'report')}-${Date.now()}.pdf`.replace(/[^a-z0-9_.-]/gi, '_'));
|
||||
|
||||
// Build PDF to file
|
||||
await new Promise((resolve, reject) => {
|
||||
const doc = a4Doc(orientation === 'landscape' ? 'landscape' : 'portrait');
|
||||
const ws = fs.createWriteStream(filePath);
|
||||
doc.pipe(ws);
|
||||
|
||||
doc.font('Helvetica-Bold').fontSize(16).text(title || 'Report');
|
||||
doc.moveDown(0.5);
|
||||
|
||||
if (kind === 'table' && table && Array.isArray(table.rows)) {
|
||||
const columns = Array.isArray(table.columns) ? table.columns : [];
|
||||
const colCount = columns.length || (table.rows[0] ? table.rows[0].length : 1);
|
||||
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||
// Slightly wider first column
|
||||
const baseWidth = Math.floor(pageWidth / Math.max(1, colCount));
|
||||
const colWidths = new Array(colCount).fill(baseWidth);
|
||||
if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2);
|
||||
|
||||
// Header band
|
||||
if (columns.length) {
|
||||
let x = doc.page.margins.left;
|
||||
const y = doc.y;
|
||||
doc.save();
|
||||
doc.rect(x, y, pageWidth, 22).fill('#f3f4f6');
|
||||
doc.fillColor('#111827').font('Helvetica-Bold').fontSize(11);
|
||||
columns.forEach((h, i) => {
|
||||
const w = colWidths[i] || baseWidth;
|
||||
doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 });
|
||||
x += w;
|
||||
});
|
||||
doc.restore();
|
||||
doc.moveDown(1.6);
|
||||
}
|
||||
|
||||
// Rows zebra
|
||||
const rows = table.rows;
|
||||
rows.forEach((row, idx) => {
|
||||
const rowY = doc.y;
|
||||
const rowH = 18;
|
||||
const bg = idx % 2 === 0 ? '#ffffff' : '#f9fafb';
|
||||
doc.save();
|
||||
doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore();
|
||||
let x = doc.page.margins.left;
|
||||
row.forEach((cell, i) => {
|
||||
const w = colWidths[i] || baseWidth;
|
||||
doc.fillColor('#111827').font('Helvetica').fontSize(10).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 });
|
||||
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke();
|
||||
x += w;
|
||||
});
|
||||
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left + pageWidth, rowY - 2).lineTo(doc.page.margins.left + pageWidth, rowY + rowH + 2).stroke();
|
||||
doc.moveDown(1.1);
|
||||
if (doc.y > doc.page.height - 40) {
|
||||
doc.addPage();
|
||||
}
|
||||
});
|
||||
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke();
|
||||
|
||||
} else if (kind === 'layered' && layered && Array.isArray(layered.sections)) {
|
||||
if (layered.header) {
|
||||
doc.font('Helvetica-Bold').fontSize(13).text(layered.header);
|
||||
doc.moveDown(0.3);
|
||||
}
|
||||
doc.font('Helvetica').fontSize(11);
|
||||
for (const section of layered.sections) {
|
||||
doc.fillColor('#111827').font('Helvetica-Bold').text(String(section.title || ''), { continued: false });
|
||||
doc.moveDown(0.15);
|
||||
doc.font('Helvetica').fontSize(10);
|
||||
if (Array.isArray(section.items) && section.items.length) {
|
||||
for (const item of section.items) {
|
||||
doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill('#374151').stroke();
|
||||
doc.fillColor('#111827');
|
||||
doc.text(' ' + String(item || ''), doc.page.margins.left + 8, doc.y, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 8 });
|
||||
doc.moveDown(0.2);
|
||||
}
|
||||
} else {
|
||||
doc.text('No items');
|
||||
}
|
||||
doc.moveDown(0.5);
|
||||
if (doc.y > doc.page.height - 60) doc.addPage();
|
||||
}
|
||||
} else {
|
||||
doc.font('Helvetica').text('No content');
|
||||
}
|
||||
|
||||
doc.end();
|
||||
ws.on('finish', resolve);
|
||||
ws.on('error', reject);
|
||||
});
|
||||
|
||||
// Send email using nodemailer (same config as tickets)
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.EMAIL_HOST,
|
||||
port: process.env.EMAIL_PORT,
|
||||
secure: process.env.EMAIL_PORT === '465',
|
||||
auth: { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS }
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: process.env.EMAIL_FROM,
|
||||
to: user.email,
|
||||
subject: subject || (title ? `${title} PDF` : 'Report PDF'),
|
||||
text: body || 'Please find your report attached.',
|
||||
attachments: [{ filename: path.basename(filePath), path: filePath, contentType: 'application/pdf' }]
|
||||
});
|
||||
|
||||
// Clean
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
|
||||
res.json({ message: `Report emailed to ${user.email}` });
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { generatePdf, emailPdf };
|
||||
@@ -0,0 +1,109 @@
|
||||
const { listJobs, getJob, updateJob, deleteJob } = require('../utils/scheduledEmails');
|
||||
|
||||
// Normalize job for client UI
|
||||
function toClient(job) {
|
||||
const kind = job.broadcast ? 'broadcast' : (job.eventId ? 'attendees' : 'unknown');
|
||||
const subject = job?.payload?.subject || '';
|
||||
const html = job?.payload?.html || '';
|
||||
const text = job?.payload?.text || '';
|
||||
return {
|
||||
id: job.id,
|
||||
kind,
|
||||
eventId: job.eventId || null,
|
||||
broadcast: !!job.broadcast,
|
||||
scheduledAt: job.scheduledAt,
|
||||
createdAt: job.createdAt,
|
||||
status: job.status,
|
||||
attempts: job.attempts || 0,
|
||||
sentAt: job.sentAt || null,
|
||||
lastError: job.lastError || null,
|
||||
subject,
|
||||
hasHtml: !!html,
|
||||
hasText: !!text,
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/scheduled-emails
|
||||
// Returns jobs excluding emails sent more than a week ago
|
||||
const listScheduledEmails = async (req, res) => {
|
||||
try {
|
||||
const raw = listJobs();
|
||||
const now = new Date();
|
||||
const weekMs = 7 * 24 * 60 * 60 * 1000;
|
||||
const filtered = raw.filter(j => {
|
||||
if (j.status === 'sent' && j.sentAt) {
|
||||
const sentAt = new Date(j.sentAt).getTime();
|
||||
return (now.getTime() - sentAt) <= weekMs;
|
||||
}
|
||||
// Include queued, sending, error by default
|
||||
return true;
|
||||
})
|
||||
// Provide most-relevant first: queued -> sending -> error -> recent sent
|
||||
.sort((a, b) => {
|
||||
const order = { queued: 0, sending: 1, error: 2, sent: 3 };
|
||||
const oa = order[a.status] ?? 99;
|
||||
const ob = order[b.status] ?? 99;
|
||||
if (oa !== ob) return oa - ob;
|
||||
// Then by scheduledAt asc
|
||||
return new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime();
|
||||
})
|
||||
.map(toClient);
|
||||
|
||||
return res.json({ jobs: filtered });
|
||||
} catch (e) {
|
||||
return res.status(400).json({ message: e?.message || 'Failed to list scheduled emails' });
|
||||
}
|
||||
};
|
||||
|
||||
// PATCH /api/scheduled-emails/:id
|
||||
// Allows editing scheduledAt, subject, html/text on queued jobs only
|
||||
const updateScheduledEmail = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const job = getJob(id);
|
||||
if (!job) return res.status(404).json({ message: 'Job not found' });
|
||||
if (job.status !== 'queued') return res.status(400).json({ message: 'Only queued jobs can be edited' });
|
||||
|
||||
const { scheduledAt, subject, html, text } = req.body || {};
|
||||
|
||||
const patch = {};
|
||||
if (scheduledAt) {
|
||||
const when = new Date(scheduledAt);
|
||||
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
|
||||
patch.scheduledAt = when.toISOString();
|
||||
}
|
||||
if (subject != null || html != null || text != null) {
|
||||
const payload = { ...(job.payload || {}) };
|
||||
if (subject != null) payload.subject = subject;
|
||||
if (html != null || text != null) {
|
||||
// If html provided explicitly, set html; if text provided, set text
|
||||
if (html != null) payload.html = html;
|
||||
if (text != null) payload.text = text;
|
||||
}
|
||||
patch.payload = payload;
|
||||
}
|
||||
|
||||
const updated = updateJob(id, patch);
|
||||
return res.json({ message: 'Updated', job: toClient(updated) });
|
||||
} catch (e) {
|
||||
return res.status(400).json({ message: e?.message || 'Failed to update job' });
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE /api/scheduled-emails/:id
|
||||
// Only queued jobs can be removed
|
||||
const deleteScheduledEmail = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const job = getJob(id);
|
||||
if (!job) return res.status(404).json({ message: 'Job not found' });
|
||||
if (job.status !== 'queued') return res.status(400).json({ message: 'Only queued jobs can be deleted' });
|
||||
const ok = deleteJob(id);
|
||||
if (!ok) return res.status(404).json({ message: 'Job not found' });
|
||||
return res.json({ message: 'Deleted' });
|
||||
} catch (e) {
|
||||
return res.status(400).json({ message: e?.message || 'Failed to delete job' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { listScheduledEmails, updateScheduledEmail, deleteScheduledEmail };
|
||||
@@ -0,0 +1,190 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
//
|
||||
// @desc Get sections (optionally by event)
|
||||
// @route GET /api/sections?eventId=xxx
|
||||
// @access Private/Staff
|
||||
//
|
||||
const getSections = async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.query;
|
||||
|
||||
const where = eventId && eventId !== 'all'
|
||||
? { eventId }
|
||||
: undefined;
|
||||
|
||||
const sections = await prisma.section.findMany({
|
||||
where,
|
||||
include: {
|
||||
allowedOptions: {
|
||||
include: {
|
||||
eventOption: true
|
||||
}
|
||||
},
|
||||
event: {
|
||||
select: { id: true, title: true }
|
||||
}
|
||||
},
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
|
||||
res.json(sections);
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// @desc Create section
|
||||
// @route POST /api/sections
|
||||
// @access Private/Admin/Supervisor
|
||||
//
|
||||
const createSection = async (req, res) => {
|
||||
try {
|
||||
const { eventId, name, allowedOptionIds } = req.body;
|
||||
|
||||
if (!eventId || !name) {
|
||||
res.status(400);
|
||||
throw new Error('Event and section name are required');
|
||||
}
|
||||
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId }
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
const section = await prisma.section.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
eventId,
|
||||
name,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
// Attach allowed options if provided
|
||||
if (Array.isArray(allowedOptionIds) && allowedOptionIds.length > 0) {
|
||||
await prisma.sectionOption.createMany({
|
||||
data: allowedOptionIds.map(optionId => ({
|
||||
id: uuidv4(),
|
||||
sectionId: section.id,
|
||||
eventOptionId: optionId
|
||||
})),
|
||||
skipDuplicates: true
|
||||
});
|
||||
}
|
||||
|
||||
const fullSection = await prisma.section.findUnique({
|
||||
where: { id: section.id },
|
||||
include: {
|
||||
allowedOptions: {
|
||||
include: { eventOption: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json(fullSection);
|
||||
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// @desc Update section
|
||||
// @route PUT /api/sections/:id
|
||||
// @access Private/Admin/Supervisor
|
||||
//
|
||||
const updateSection = async (req, res) => {
|
||||
try {
|
||||
const { name, allowedOptionIds } = req.body;
|
||||
|
||||
const section = await prisma.section.findUnique({
|
||||
where: { id: req.params.id }
|
||||
});
|
||||
|
||||
if (!section) {
|
||||
res.status(404);
|
||||
throw new Error('Section not found');
|
||||
}
|
||||
|
||||
const updatedSection = await prisma.section.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
name: name || section.name,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
// If allowed options supplied → reset them
|
||||
if (Array.isArray(allowedOptionIds)) {
|
||||
|
||||
await prisma.sectionOption.deleteMany({
|
||||
where: { sectionId: section.id }
|
||||
});
|
||||
|
||||
if (allowedOptionIds.length > 0) {
|
||||
await prisma.sectionOption.createMany({
|
||||
data: allowedOptionIds.map(optionId => ({
|
||||
id: uuidv4(),
|
||||
sectionId: section.id,
|
||||
eventOptionId: optionId
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fullSection = await prisma.section.findUnique({
|
||||
where: { id: section.id },
|
||||
include: {
|
||||
allowedOptions: {
|
||||
include: { eventOption: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json(fullSection);
|
||||
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// @desc Delete section
|
||||
// @route DELETE /api/sections/:id
|
||||
// @access Private/Admin
|
||||
//
|
||||
const deleteSection = async (req, res) => {
|
||||
try {
|
||||
const section = await prisma.section.findUnique({
|
||||
where: { id: req.params.id }
|
||||
});
|
||||
|
||||
if (!section) {
|
||||
res.status(404);
|
||||
throw new Error('Section not found');
|
||||
}
|
||||
|
||||
await prisma.section.delete({
|
||||
where: { id: section.id }
|
||||
});
|
||||
|
||||
res.json({ message: 'Section deleted' });
|
||||
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getSections,
|
||||
createSection,
|
||||
updateSection,
|
||||
deleteSection
|
||||
};
|
||||
@@ -0,0 +1,311 @@
|
||||
const prisma = require('../config/db');
|
||||
const { hashPassword, generateToken } = require('../config/auth');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { invalidate: invalidateSettingsCache, warmCache, ENCRYPTED_KEYS } = require('../utils/settingsCache');
|
||||
const { encrypt, decrypt, isEncrypted } = require('../utils/encryption');
|
||||
|
||||
// Keys safe to return without auth — includes legal keys needed by public legal pages
|
||||
const PUBLIC_KEYS = [
|
||||
'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address',
|
||||
'accent_color', 'logo_url', 'setup_complete', 'app_base_url',
|
||||
// Legal pages
|
||||
'legal_operator_name', 'legal_io_name', 'legal_io_email',
|
||||
'legal_website_url', 'legal_effective_date',
|
||||
];
|
||||
|
||||
// @desc Get public app settings
|
||||
// @route GET /api/settings
|
||||
// @access Public
|
||||
const getSettings = async (req, res) => {
|
||||
try {
|
||||
const rows = await prisma.appSetting.findMany({ where: { key: { in: PUBLIC_KEYS } } });
|
||||
const settings = {};
|
||||
for (const r of rows) settings[r.key] = r.value;
|
||||
res.json(settings);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get ALL app settings (admin view — encrypted fields masked)
|
||||
// @route GET /api/settings/all
|
||||
// @access Admin
|
||||
const getAllSettings = async (req, res) => {
|
||||
try {
|
||||
const rows = await prisma.appSetting.findMany();
|
||||
const settings = {};
|
||||
for (const r of rows) {
|
||||
if (ENCRYPTED_KEYS.has(r.key)) {
|
||||
// Return a sentinel so the UI knows the value is set, without exposing it.
|
||||
// smtp_user (email address) we can safely return as-is after decryption so
|
||||
// the admin can see what address is configured; smtp_pass we fully mask.
|
||||
if (r.key === 'smtp_pass') {
|
||||
settings[r.key] = r.value ? '••••••••' : '';
|
||||
} else {
|
||||
// smtp_user — decrypt and return so admin can see/edit it
|
||||
try {
|
||||
settings[r.key] = isEncrypted(r.value) ? decrypt(r.value) : r.value;
|
||||
} catch {
|
||||
settings[r.key] = '';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
settings[r.key] = r.value;
|
||||
}
|
||||
}
|
||||
res.json(settings);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Upsert one or more app settings
|
||||
// @route PUT /api/settings
|
||||
// @access Admin
|
||||
const updateSettings = async (req, res) => {
|
||||
try {
|
||||
const updates = req.body;
|
||||
if (!updates || typeof updates !== 'object') {
|
||||
res.status(400); throw new Error('Body must be a key→value object');
|
||||
}
|
||||
|
||||
const ops = Object.entries(updates)
|
||||
.filter(([, v]) => v !== undefined && v !== null)
|
||||
.map(([key, value]) => {
|
||||
let storedValue = String(value);
|
||||
|
||||
// Encrypt sensitive keys before storing
|
||||
if (ENCRYPTED_KEYS.has(key)) {
|
||||
// If the frontend sends the masking sentinel back, skip this key (user didn't change it)
|
||||
if (storedValue === '••••••••') return null;
|
||||
if (storedValue === '') {
|
||||
// Blank = clear the setting
|
||||
return prisma.appSetting.upsert({
|
||||
where: { key },
|
||||
update: { value: '' },
|
||||
create: { key, value: '' },
|
||||
});
|
||||
}
|
||||
storedValue = encrypt(storedValue);
|
||||
}
|
||||
|
||||
return prisma.appSetting.upsert({
|
||||
where: { key },
|
||||
update: { value: storedValue },
|
||||
create: { key, value: storedValue },
|
||||
});
|
||||
})
|
||||
.filter(Boolean); // remove nulls (masked password skips)
|
||||
|
||||
if (ops.length) await prisma.$transaction(ops);
|
||||
invalidateSettingsCache();
|
||||
await warmCache(); // ensure in-memory cache reflects the new values before responding
|
||||
res.json({ message: 'Settings saved' });
|
||||
} catch (e) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Check whether first-time setup is still needed
|
||||
// @route GET /api/settings/needs-setup
|
||||
// @access Public
|
||||
const needsSetup = async (req, res) => {
|
||||
try {
|
||||
const done = await prisma.appSetting.findUnique({ where: { key: 'setup_complete' } });
|
||||
res.json({ needsSetup: done?.value !== 'true' });
|
||||
} catch {
|
||||
// DB unreachable — don't block the app
|
||||
res.json({ needsSetup: false });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Register the first admin account during setup — returns a JWT for use in subsequent setup steps
|
||||
// @route POST /api/setup/register
|
||||
// @access Public (one-time only — blocked once users exist)
|
||||
const setupRegister = async (req, res) => {
|
||||
try {
|
||||
const count = await prisma.user.count();
|
||||
if (count > 0) {
|
||||
res.status(403); throw new Error('Setup has already been completed');
|
||||
}
|
||||
|
||||
const { adminName, adminEmail, adminPassword } = req.body;
|
||||
|
||||
if (!adminName?.trim()) { res.status(400); throw new Error('Admin name is required'); }
|
||||
if (!adminEmail?.trim()) { res.status(400); throw new Error('Admin email is required'); }
|
||||
if (!adminPassword || adminPassword.length < 8) {
|
||||
res.status(400); throw new Error('Password must be at least 8 characters');
|
||||
}
|
||||
|
||||
const hashed = await hashPassword(adminPassword);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
name: adminName.trim(),
|
||||
email: adminEmail.trim().toLowerCase(),
|
||||
password: hashed,
|
||||
role: 'admin',
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const token = generateToken(user.id, user.role, user.tokenVersion ?? 0);
|
||||
res.json({ token, name: user.name, email: user.email });
|
||||
} catch (e) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Complete first-time setup: persist settings (admin must already be registered via /api/setup/register)
|
||||
// @route POST /api/setup
|
||||
// @access Admin (use token returned by /api/setup/register)
|
||||
const runSetup = async (req, res) => {
|
||||
try {
|
||||
const setupDone = await prisma.appSetting.findUnique({ where: { key: 'setup_complete' } });
|
||||
if (setupDone?.value === 'true') {
|
||||
res.status(403); throw new Error('Setup has already been completed');
|
||||
}
|
||||
|
||||
const { settings = {} } = req.body;
|
||||
|
||||
const toSave = { ...settings, setup_complete: 'true' };
|
||||
const ops = Object.entries(toSave)
|
||||
.filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '')
|
||||
.map(([key, value]) => {
|
||||
let storedValue = String(value);
|
||||
if (ENCRYPTED_KEYS.has(key)) storedValue = encrypt(storedValue);
|
||||
return prisma.appSetting.upsert({
|
||||
where: { key },
|
||||
update: { value: storedValue },
|
||||
create: { key, value: storedValue },
|
||||
});
|
||||
});
|
||||
await prisma.$transaction(ops);
|
||||
invalidateSettingsCache();
|
||||
await warmCache();
|
||||
|
||||
res.json({ message: 'Setup complete. You can now log in.' });
|
||||
} catch (e) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Translates raw nodemailer / Node.js network errors into plain-language messages
|
||||
* suitable for display to an admin who may not know what ECONNREFUSED means.
|
||||
*/
|
||||
function friendlySmtpError(e) {
|
||||
const code = e.code || '';
|
||||
const msg = (e.message || '').toLowerCase();
|
||||
const resp = (e.response || '').toLowerCase();
|
||||
|
||||
// Authentication failures (530 = Microsoft "Client not authenticated", 535 = standard auth failure)
|
||||
if (code === 'EAUTH' || e.responseCode === 535 || e.responseCode === 530 || msg.includes('invalid login') || msg.includes('username and password') || msg.includes('not authenticated') || resp.includes('badcredentials') || resp.includes('authentication')) {
|
||||
return 'Authentication failed — check your SMTP username and password.';
|
||||
}
|
||||
|
||||
// Wrong certificate / TLS mismatch
|
||||
if (code === 'ESOCKET' && (msg.includes('wrong version') || msg.includes('ssl') || msg.includes('tls'))) {
|
||||
return 'TLS/SSL error — try toggling the "Use TLS/SSL" option or switching the port between 465 and 587.';
|
||||
}
|
||||
if (msg.includes('unable_to_verify') || msg.includes('self signed') || msg.includes('certificate')) {
|
||||
return 'SSL certificate error — the server\'s certificate could not be verified. Check the port and TLS setting.';
|
||||
}
|
||||
|
||||
// Connection refused or timed out
|
||||
if (code === 'ECONNREFUSED') {
|
||||
return 'Connection refused — no mail server responded on that host and port. Check the host and port settings.';
|
||||
}
|
||||
if (code === 'ETIMEDOUT' || code === 'ESOCKETTIMEDOUT' || msg.includes('timed out')) {
|
||||
return 'Connection timed out — the server did not respond in time. Check the host and port, or try a different port.';
|
||||
}
|
||||
|
||||
// DNS / hostname not found
|
||||
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
|
||||
return 'Host not found — the SMTP hostname could not be resolved. Check for typos in the server address.';
|
||||
}
|
||||
|
||||
// Network unreachable
|
||||
if (code === 'ENETUNREACH' || code === 'EHOSTUNREACH') {
|
||||
return 'Network unreachable — the server could not be reached. Check your network connection and the host address.';
|
||||
}
|
||||
|
||||
// Connection reset
|
||||
if (code === 'ECONNRESET') {
|
||||
return 'Connection was reset by the server — this can indicate a port mismatch or a firewall block.';
|
||||
}
|
||||
|
||||
// Generic SMTP error with a response code
|
||||
if (e.responseCode) {
|
||||
return `SMTP error ${e.responseCode}: ${e.response || e.message}`;
|
||||
}
|
||||
|
||||
// Fallback — strip overly long technical strings but keep it readable
|
||||
const raw = e.message || 'Unknown error';
|
||||
const trimmed = raw.length > 120 ? raw.slice(0, 120) + '…' : raw;
|
||||
return `Could not connect: ${trimmed}`;
|
||||
}
|
||||
|
||||
// @desc Test SMTP connection using current settings (DB or env fallbacks)
|
||||
// Optionally accepts { host, port, secure, user, pass, from } in the request
|
||||
// body to test unsaved values without saving them first.
|
||||
// @route POST /api/settings/test-smtp
|
||||
// @access Admin
|
||||
const testSmtp = async (req, res) => {
|
||||
const nodemailer = require('nodemailer');
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
|
||||
try {
|
||||
// Prefer values from the request body so admins can test before saving.
|
||||
// Fall back to cache → env vars.
|
||||
const host = req.body.host || getSettingSync('smtp_host', process.env.SMTP_HOST || process.env.EMAIL_HOST || '');
|
||||
const port = req.body.port || getSettingSync('smtp_port', process.env.SMTP_PORT || process.env.EMAIL_PORT || '587');
|
||||
const secure = req.body.secure !== undefined
|
||||
? (req.body.secure === true || req.body.secure === 'true')
|
||||
: (getSettingSync('smtp_secure', process.env.SMTP_SECURE || 'false').toLowerCase() === 'true');
|
||||
const user = req.body.user || getSettingSync('smtp_user', process.env.SMTP_USER || process.env.EMAIL_USER || '');
|
||||
// For pass: if a real value is supplied in body use it; if "••••••••" is sent, read from cache.
|
||||
let pass = req.body.pass || '';
|
||||
if (!pass || pass === '••••••••') {
|
||||
pass = getSettingSync('smtp_pass', process.env.SMTP_PASS || process.env.EMAIL_PASS || '');
|
||||
} else {
|
||||
// Body supplied a plaintext password — decrypt it if it happens to be encrypted (shouldn't be, but guard anyway)
|
||||
const { isEncrypted: _ie, decrypt: _d } = require('../utils/encryption');
|
||||
if (_ie(pass)) pass = _d(pass);
|
||||
}
|
||||
const from = req.body.from || getSettingSync('smtp_from', process.env.MAIL_FROM || process.env.EMAIL_FROM || '');
|
||||
|
||||
if (!host) {
|
||||
res.status(400); throw new Error('SMTP host is not configured');
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port: parseInt(port, 10) || 587,
|
||||
secure: secure || String(port) === '465',
|
||||
auth: user && pass ? { user, pass } : undefined,
|
||||
});
|
||||
|
||||
// verify() checks connectivity and authentication without sending a message
|
||||
await transporter.verify();
|
||||
|
||||
// Send a real test email to the authenticated user so there's visible proof
|
||||
const adminEmail = req.user?.email;
|
||||
if (adminEmail) {
|
||||
await transporter.sendMail({
|
||||
from: from || user || 'no-reply@hope-events.local',
|
||||
to: adminEmail,
|
||||
subject: 'SMTP test — Hope Events',
|
||||
text: `This is a test email sent from the Hope Events admin panel to confirm that your SMTP settings are working correctly.\n\nHost: ${host}:${port}\nFrom: ${from || user}`,
|
||||
html: `<p>This is a test email sent from the <strong>Hope Events</strong> admin panel to confirm that your SMTP settings are working correctly.</p><p><strong>Host:</strong> ${host}:${port}<br/><strong>From:</strong> ${from || user}</p>`,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: `SMTP connection verified${adminEmail ? ` — a test email has been sent to ${adminEmail}` : ''}` });
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: friendlySmtpError(e), raw: e.message || String(e) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getSettings, getAllSettings, updateSettings, needsSetup, setupRegister, runSetup, testSmtp };
|
||||
@@ -0,0 +1,141 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
|
||||
// Shared building blocks for the per-dashboard stats endpoints below. Each dashboard
|
||||
// (staff/supervisor/admin) gets exactly one endpoint that returns only what it renders,
|
||||
// computed with aggregate queries — never a full payments/events list shipped to the
|
||||
// client just to be reduced down to a couple of numbers.
|
||||
|
||||
async function computeScanStats(userId) {
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const whereBase = { scannedAt: { gte: startOfDay } };
|
||||
|
||||
const [totalToday, myToday, lastHour, byStaffRaw] = await Promise.all([
|
||||
prisma.ticketUsage.count({ where: whereBase }),
|
||||
prisma.ticketUsage.count({ where: { ...whereBase, scannedById: userId } }),
|
||||
prisma.ticketUsage.count({ where: { scannedAt: { gte: new Date(Date.now() - 60 * 60 * 1000) } } }),
|
||||
prisma.ticketUsage.groupBy({ by: ['scannedById'], where: whereBase, _count: { _all: true } }),
|
||||
]);
|
||||
|
||||
const staffIds = byStaffRaw.map((b) => b.scannedById);
|
||||
const staffUsers = staffIds.length > 0
|
||||
? await prisma.user.findMany({ where: { id: { in: staffIds } }, select: { id: true, name: true } })
|
||||
: [];
|
||||
const nameMap = Object.fromEntries(staffUsers.map((u) => [u.id, u.name]));
|
||||
|
||||
return {
|
||||
totalToday,
|
||||
myToday,
|
||||
lastHour,
|
||||
byStaff: byStaffRaw.map((b) => ({ scannedById: b.scannedById, name: nameMap[b.scannedById] || 'Staff', count: b._count._all })),
|
||||
};
|
||||
}
|
||||
|
||||
function getRecentScans(limit = 10) {
|
||||
return prisma.ticketUsage.findMany({
|
||||
orderBy: { scannedAt: 'desc' },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
scannedAt: true,
|
||||
quantityRedeemed: true,
|
||||
scannedBy: { select: { id: true, name: true } },
|
||||
ticket: {
|
||||
select: {
|
||||
id: true,
|
||||
event: { select: { title: true } },
|
||||
registrationOption: { select: { eventOption: { select: { name: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getActiveEventsCount() {
|
||||
return prisma.event.count({ where: { isActive: true, endDate: { gte: new Date() } } });
|
||||
}
|
||||
|
||||
async function computePaymentStats({ includeWeekMonth }) {
|
||||
const startOfDay = new Date(new Date().setHours(0, 0, 0, 0));
|
||||
|
||||
const queries = [
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: startOfDay } } }),
|
||||
prisma.payment.count({ where: { isDonation: true, createdAt: { gte: startOfDay } } }),
|
||||
];
|
||||
if (includeWeekMonth) {
|
||||
const lastWeek = new Date(startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const lastMonth = new Date(startOfDay.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
queries.push(
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastWeek } } }),
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth } } }),
|
||||
);
|
||||
}
|
||||
|
||||
const [totalToday, donationsToday, totalWeek, totalMonth] = await Promise.all(queries);
|
||||
|
||||
const stats = {
|
||||
totalToday: totalToday._sum.amount || 0,
|
||||
donationsToday,
|
||||
};
|
||||
if (includeWeekMonth) {
|
||||
stats.totalWeek = totalWeek._sum.amount || 0;
|
||||
stats.totalMonth = totalMonth._sum.amount || 0;
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
// @desc All stats the staff dashboard needs, in one call
|
||||
// @route GET /api/stats/staff
|
||||
// @access Private/Staff+
|
||||
const getStaffDashboardStats = async (req, res) => {
|
||||
try {
|
||||
const [scanStats, recentScans] = await Promise.all([
|
||||
computeScanStats(req.user.id),
|
||||
getRecentScans(10),
|
||||
]);
|
||||
res.json({ scanStats, recentScans });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc All stats the supervisor dashboard needs, in one call
|
||||
// @route GET /api/stats/supervisor
|
||||
// @access Private/Supervisor+
|
||||
const getSupervisorDashboardStats = async (req, res) => {
|
||||
try {
|
||||
const [scanStats, recentScans, paymentStats, activeEventsCount] = await Promise.all([
|
||||
computeScanStats(req.user.id),
|
||||
getRecentScans(10),
|
||||
computePaymentStats({ includeWeekMonth: false }),
|
||||
getActiveEventsCount(),
|
||||
]);
|
||||
res.json({ scanStats, recentScans, paymentStats, activeEventsCount });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc All stats the admin dashboard needs, in one call
|
||||
// @route GET /api/stats/admin
|
||||
// @access Private/Admin
|
||||
const getAdminDashboardStats = async (req, res) => {
|
||||
try {
|
||||
const [scanStats, recentScans, paymentStats, activeEventsCount] = await Promise.all([
|
||||
computeScanStats(req.user.id),
|
||||
getRecentScans(10),
|
||||
computePaymentStats({ includeWeekMonth: true }),
|
||||
getActiveEventsCount(),
|
||||
]);
|
||||
res.json({ scanStats, recentScans, paymentStats, activeEventsCount });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getStaffDashboardStats,
|
||||
getSupervisorDashboardStats,
|
||||
getAdminDashboardStats,
|
||||
};
|
||||
@@ -0,0 +1,795 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { assertRegistrationEventOpen } = require('../utils/cashupUtils');
|
||||
|
||||
// @desc Generate tickets for a registration
|
||||
// @route POST /api/tickets/generate
|
||||
// @access Private/Admin
|
||||
const generateTickets = async (req, res) => {
|
||||
try {
|
||||
const { registrationId } = req.body;
|
||||
|
||||
if (!registrationId) {
|
||||
res.status(400);
|
||||
throw new Error('registrationId is required');
|
||||
}
|
||||
|
||||
await assertRegistrationEventOpen(registrationId, res);
|
||||
|
||||
// Delegate fully to the shared utility which handles deduplication/consolidation
|
||||
const newTickets = await generateTicketsForRegistration(registrationId);
|
||||
|
||||
// Fetch the final canonical ticket set (one per option)
|
||||
const options = await prisma.registrationOption.findMany({ where: { registrationId } });
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where: { registrationOptionId: { in: options.map(o => o.id) } },
|
||||
include: {
|
||||
registrationOption: { include: { eventOption: true } },
|
||||
user: true,
|
||||
event: true
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
message: `${newTickets.length} ticket(s) generated, ${tickets.length} total`,
|
||||
tickets
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get all tickets
|
||||
// @route GET /api/tickets
|
||||
// @access Private/Admin
|
||||
const getTickets = async (req, res) => {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 100));
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const include = {
|
||||
registrationOption: {
|
||||
include: {
|
||||
eventOption: true,
|
||||
registration: {
|
||||
include: { user: { select: { id: true, name: true, email: true, phoneNumber: true } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } }
|
||||
};
|
||||
|
||||
const [tickets, total] = await prisma.$transaction([
|
||||
prisma.ticket.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.ticket.count()
|
||||
]);
|
||||
|
||||
res.json({ data: tickets, total, page, limit, pages: Math.ceil(total / limit) });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get user tickets
|
||||
// @route GET /api/tickets/mytickets
|
||||
// @access Private
|
||||
const getUserTickets = async (req, res) => {
|
||||
try {
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
registrationOption: { registration: { status: { not: 'cancelled' } } }
|
||||
},
|
||||
include: {
|
||||
registrationOption: {
|
||||
include: {
|
||||
eventOption: true,
|
||||
variant: true,
|
||||
registration: true
|
||||
}
|
||||
},
|
||||
event: true,
|
||||
usages: {
|
||||
include: {
|
||||
scannedBy: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json(tickets);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get ticket by ID
|
||||
// @route GET /api/tickets/:id
|
||||
// @access Private
|
||||
const getTicketById = async (req, res) => {
|
||||
try {
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
registrationOption: {
|
||||
include: {
|
||||
eventOption: true,
|
||||
registration: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phoneNumber: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
event: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phoneNumber: true
|
||||
}
|
||||
},
|
||||
usages: {
|
||||
include: {
|
||||
scannedBy: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
res.status(404);
|
||||
throw new Error('Ticket not found');
|
||||
}
|
||||
|
||||
// Check if user is authorized to view this ticket
|
||||
if (ticket.userId !== req.user.id && req.user.role !== 'admin' && req.user.role !== 'supervisor' && req.user.role !== 'staff') {
|
||||
res.status(403);
|
||||
throw new Error('Not authorized to view this ticket');
|
||||
}
|
||||
|
||||
res.json(ticket);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get ticket by QR code
|
||||
// @route GET /api/tickets/qr/:qrCode
|
||||
// @access Private/Staff
|
||||
const getTicketByQrCode = async (req, res) => {
|
||||
try {
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { qrCode: req.params.qrCode },
|
||||
include: {
|
||||
registrationOption: {
|
||||
include: {
|
||||
eventOption: true,
|
||||
registration: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phoneNumber: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
event: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phoneNumber: true
|
||||
}
|
||||
},
|
||||
usages: {
|
||||
include: {
|
||||
scannedBy: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
res.status(404);
|
||||
throw new Error('Ticket not found');
|
||||
}
|
||||
|
||||
res.json(ticket);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Lightweight ticket preview for the scan-confirm flow (only fields the confirm modal needs)
|
||||
// @route GET /api/tickets/scan-preview/:qrCode
|
||||
// @access Private/Staff
|
||||
const getScanPreview = async (req, res) => {
|
||||
try {
|
||||
// Single JOIN query instead of 6 sequential Prisma round-trips (critical for remote DBs)
|
||||
const rows = await prisma.$queryRaw`
|
||||
SELECT
|
||||
t.id,
|
||||
t."qrCode",
|
||||
t.quantity,
|
||||
t."isUsed",
|
||||
t."eventId",
|
||||
ro.id AS "registrationOptionId",
|
||||
eo.id AS "eventOptionId",
|
||||
eo.name AS "eventOptionName",
|
||||
ov.id AS "variantId",
|
||||
ov.name AS "variantName",
|
||||
e.id AS "evId",
|
||||
e.title AS "eventTitle",
|
||||
u.id AS "userId",
|
||||
u.name AS "userName",
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'quantityRedeemed', tu."quantityRedeemed",
|
||||
'scannedAt', tu."scannedAt"
|
||||
)
|
||||
) FILTER (WHERE tu.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS usages
|
||||
FROM "Ticket" t
|
||||
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
|
||||
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
|
||||
LEFT JOIN "OptionVariant" ov ON ov.id = ro."variantId"
|
||||
LEFT JOIN "Event" e ON e.id = t."eventId"
|
||||
LEFT JOIN "User" u ON u.id = t."userId"
|
||||
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
|
||||
WHERE t."qrCode" = ${req.params.qrCode}
|
||||
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id
|
||||
`;
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
res.status(404);
|
||||
throw new Error('Ticket not found');
|
||||
}
|
||||
|
||||
const r = rows[0];
|
||||
res.json({
|
||||
id: r.id,
|
||||
quantity: Number(r.quantity),
|
||||
isUsed: r.isUsed,
|
||||
eventId: r.eventId,
|
||||
registrationOption: {
|
||||
id: r.registrationOptionId,
|
||||
eventOption: { id: r.eventOptionId, name: r.eventOptionName },
|
||||
variant: r.variantId ? { id: r.variantId, name: r.variantName } : null,
|
||||
},
|
||||
event: { id: r.evId, title: r.eventTitle },
|
||||
user: { id: r.userId, name: r.userName },
|
||||
usages: r.usages || []
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Scan ticket (mark as used, with optional partial quantity redemption)
|
||||
// @route POST /api/tickets/scan/:qrCode
|
||||
// @access Private/Staff
|
||||
const scanTicket = async (req, res) => {
|
||||
try {
|
||||
// Single JOIN query — avoids multiple sequential round-trips to the remote DB
|
||||
const rows = await prisma.$queryRaw`
|
||||
SELECT
|
||||
t.id,
|
||||
t.quantity,
|
||||
t."isUsed",
|
||||
t."eventId",
|
||||
e.title AS "eventTitle",
|
||||
eo.id AS "eventOptionId",
|
||||
eo.name AS "eventOptionName",
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', tu.id,
|
||||
'quantityRedeemed', tu."quantityRedeemed",
|
||||
'scannedAt', tu."scannedAt"
|
||||
)
|
||||
) FILTER (WHERE tu.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS usages
|
||||
FROM "Ticket" t
|
||||
LEFT JOIN "Event" e ON e.id = t."eventId"
|
||||
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
|
||||
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
|
||||
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
|
||||
WHERE t."qrCode" = ${req.params.qrCode}
|
||||
GROUP BY t.id, e.id, eo.id
|
||||
`;
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
res.status(404);
|
||||
throw new Error('Ticket not found');
|
||||
}
|
||||
|
||||
const r = rows[0];
|
||||
const ticket = {
|
||||
id: r.id,
|
||||
quantity: Number(r.quantity),
|
||||
isUsed: r.isUsed,
|
||||
eventId: r.eventId,
|
||||
event: { title: r.eventTitle },
|
||||
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName } },
|
||||
usages: r.usages || []
|
||||
};
|
||||
|
||||
// Optional server-side guard: ensure ticket belongs to the requested event when provided
|
||||
const providedEventId = String((req.query?.eventId || req.body?.eventId) || '').trim();
|
||||
if (providedEventId && ticket.eventId !== providedEventId) {
|
||||
return res.status(400).json({ message: 'Ticket belongs to a different event', ticket });
|
||||
}
|
||||
|
||||
// Compute how many have already been redeemed
|
||||
const totalRedeemed = (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const remaining = (ticket.quantity || 1) - totalRedeemed;
|
||||
|
||||
if (remaining <= 0) {
|
||||
return res.status(403).json({
|
||||
message: 'Ticket has already been fully used',
|
||||
ticket,
|
||||
remaining: 0
|
||||
});
|
||||
}
|
||||
|
||||
// Determine how many to redeem this scan (defaults to all remaining)
|
||||
const requestedQty = parseInt(req.body?.qty || req.body?.quantity) || remaining;
|
||||
const qtyToRedeem = Math.min(Math.max(1, requestedQty), remaining);
|
||||
|
||||
const newTotalRedeemed = totalRedeemed + qtyToRedeem;
|
||||
const newRemaining = (ticket.quantity || 1) - newTotalRedeemed;
|
||||
const fullyUsed = newRemaining <= 0;
|
||||
|
||||
// Run the usage insert and ticket status update in parallel — they don't depend on each other
|
||||
const [ticketUsage] = await Promise.all([
|
||||
prisma.ticketUsage.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
ticketId: ticket.id,
|
||||
scannedById: req.user.id,
|
||||
quantityRedeemed: qtyToRedeem,
|
||||
}
|
||||
}),
|
||||
prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { isUsed: fullyUsed, updatedAt: new Date() }
|
||||
})
|
||||
]);
|
||||
|
||||
res.json({
|
||||
message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
|
||||
ticketUsage,
|
||||
ticket,
|
||||
qtyRedeemed: qtyToRedeem,
|
||||
totalRedeemed: newTotalRedeemed,
|
||||
remaining: newRemaining,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get tickets by event
|
||||
// @route GET /api/tickets/event/:eventId
|
||||
// @access Private/Staff
|
||||
const getTicketsByEvent = async (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(1000, Math.max(1, parseInt(req.query.limit) || 1000));
|
||||
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where: { eventId: req.params.eventId },
|
||||
include: {
|
||||
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } },
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit
|
||||
});
|
||||
|
||||
res.json(tickets);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Mark tickets as email sent
|
||||
// @route PUT /api/tickets/email-sent
|
||||
// @access Private/Admin
|
||||
const markTicketsAsEmailSent = async (req, res) => {
|
||||
try {
|
||||
const { ticketIds } = req.body;
|
||||
|
||||
if (!ticketIds || !Array.isArray(ticketIds) || ticketIds.length === 0) {
|
||||
res.status(400);
|
||||
throw new Error('Ticket IDs are required');
|
||||
}
|
||||
|
||||
// Update tickets
|
||||
await prisma.ticket.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
in: ticketIds
|
||||
}
|
||||
},
|
||||
data: {
|
||||
emailSent: true,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ message: `${ticketIds.length} tickets marked as email sent` });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Email (and WhatsApp if preference set) tickets to user
|
||||
// @route POST /api/tickets/email
|
||||
// @access Private
|
||||
// Optional body param: channel ('email'|'whatsapp'|'both') — overrides user's notification preference for this request
|
||||
const emailTickets = async (req, res) => {
|
||||
try {
|
||||
// If caller specifies an explicit channel, build a minimal userOverride that forces that preference
|
||||
const { channel } = req.body || {};
|
||||
let userOverride = null;
|
||||
if (channel && ['email', 'whatsapp', 'both'].includes(channel)) {
|
||||
// Load base user data then override the preference
|
||||
const userId = req.user?.id;
|
||||
if (userId) {
|
||||
const baseUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true, name: true, phoneNumber: true, notificationPreference: true }
|
||||
});
|
||||
if (baseUser) {
|
||||
userOverride = { ...baseUser, notificationPreference: channel };
|
||||
}
|
||||
}
|
||||
}
|
||||
return await emailTicketsInternal(req, res, userOverride);
|
||||
} catch (error) {
|
||||
console.error('Error emailing tickets:', error);
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get recent ticket scans
|
||||
// @route GET /api/tickets/scans/recent
|
||||
// @access Private/Staff
|
||||
const getRecentScans = async (req, res) => {
|
||||
try {
|
||||
const limit = Math.max(1, Math.min(parseInt(req.query.limit) || 10, 100));
|
||||
const eventId = req.query.eventId || undefined;
|
||||
|
||||
const scans = await prisma.ticketUsage.findMany({
|
||||
where: eventId ? { ticket: { eventId } } : undefined,
|
||||
orderBy: { scannedAt: 'desc' },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
scannedAt: true,
|
||||
quantityRedeemed: true,
|
||||
scannedBy: { select: { id: true, name: true } },
|
||||
ticket: {
|
||||
select: {
|
||||
id: true,
|
||||
event: { select: { title: true } },
|
||||
registrationOption: { select: { eventOption: { select: { name: true } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json(scans);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get scan statistics
|
||||
// @route GET /api/tickets/scans/stats
|
||||
// @access Private/Staff
|
||||
const getScanStats = async (req, res) => {
|
||||
try {
|
||||
const eventId = req.query.eventId || undefined;
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
// Total scans today (optionally by event)
|
||||
const whereBase = {
|
||||
scannedAt: { gte: startOfDay },
|
||||
...(eventId ? { ticket: { eventId } } : {})
|
||||
};
|
||||
|
||||
const [totalToday, myToday, lastHour] = await Promise.all([
|
||||
prisma.ticketUsage.count({ where: whereBase }),
|
||||
prisma.ticketUsage.count({ where: { ...whereBase, scannedById: req.user.id } }),
|
||||
prisma.ticketUsage.count({
|
||||
where: {
|
||||
...(eventId ? { ticket: { eventId } } : {}),
|
||||
scannedAt: { gte: new Date(Date.now() - 60 * 60 * 1000) }
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
// Per-scanner breakdown today
|
||||
const byStaff = await prisma.ticketUsage.groupBy({
|
||||
by: ['scannedById'],
|
||||
where: whereBase,
|
||||
_count: { _all: true }
|
||||
});
|
||||
const staffIds = byStaff.map(b => b.scannedById);
|
||||
const staffUsers = staffIds.length > 0 ? await prisma.user.findMany({
|
||||
where: { id: { in: staffIds } },
|
||||
select: { id: true, name: true }
|
||||
}) : [];
|
||||
const nameMap = Object.fromEntries(staffUsers.map(u => [u.id, u.name]));
|
||||
|
||||
res.json({
|
||||
totalToday,
|
||||
myToday,
|
||||
lastHour,
|
||||
byStaff: byStaff.map(b => ({ scannedById: b.scannedById, name: nameMap[b.scannedById] || 'Staff', count: b._count._all }))
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Send tickets to a specific phone/email (staff override for at-the-door)
|
||||
// @route POST /api/tickets/send-to
|
||||
// @access Private/Staff
|
||||
const sendTicketsTo = async (req, res) => {
|
||||
try {
|
||||
const { registrationId, ticketIds, channel, overridePhone, overrideEmail } = req.body;
|
||||
if (!registrationId && (!ticketIds || !Array.isArray(ticketIds) || ticketIds.length === 0)) {
|
||||
res.status(400); throw new Error('registrationId or ticketIds required');
|
||||
}
|
||||
if (!channel || !['email','whatsapp','both'].includes(channel)) {
|
||||
res.status(400); throw new Error('channel must be email, whatsapp, or both');
|
||||
}
|
||||
|
||||
// Determine which user owns the tickets to get their default contact info
|
||||
let ownerUserId;
|
||||
if (registrationId) {
|
||||
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true } });
|
||||
if (!reg) { res.status(404); throw new Error('Registration not found'); }
|
||||
ownerUserId = reg.userId;
|
||||
} else {
|
||||
const firstTicket = await prisma.ticket.findUnique({ where: { id: ticketIds[0] }, select: { userId: true } });
|
||||
if (!firstTicket) { res.status(404); throw new Error('Ticket not found'); }
|
||||
ownerUserId = firstTicket.userId;
|
||||
}
|
||||
|
||||
// Use a mock req that targets the ticket owner; override contact details in user object via closure
|
||||
const originalUser = await prisma.user.findUnique({
|
||||
where: { id: ownerUserId },
|
||||
select: { id: true, email: true, name: true, phoneNumber: true, notificationPreference: true }
|
||||
});
|
||||
if (!originalUser) { res.status(404); throw new Error('Ticket owner not found'); }
|
||||
|
||||
// Build a virtual user with override contact details
|
||||
const virtualUser = {
|
||||
...originalUser,
|
||||
email: overrideEmail || originalUser.email,
|
||||
phoneNumber: overridePhone || originalUser.phoneNumber,
|
||||
// Force the preference to match the requested channel
|
||||
notificationPreference: channel,
|
||||
};
|
||||
|
||||
// Reuse emailTickets logic via mock request but with virtual user
|
||||
// We build a minimal mock and call the internal flow directly
|
||||
const mockBody = registrationId ? { registrationId } : { ticketIds };
|
||||
const mockReq = { user: { id: ownerUserId }, body: mockBody, _virtualUser: virtualUser };
|
||||
const mockRes = { status: () => mockRes, json: (body) => { res.json(body); } };
|
||||
|
||||
// Call emailTickets with the virtual user override
|
||||
await emailTicketsInternal(mockReq, mockRes, virtualUser);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// Internal helper used by both emailTickets and sendTicketsTo
|
||||
async function emailTicketsInternal(req, res, userOverride) {
|
||||
const { ticketIds, registrationId } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
const user = userOverride || await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true, name: true, phoneNumber: true, notificationPreference: true }
|
||||
});
|
||||
if (!user) { res.status(404); throw new Error('User not found'); }
|
||||
const pref = user.notificationPreference || 'email';
|
||||
const noValidEmail = !user.email || user.email.endsWith('@guest.local');
|
||||
// For users without a valid email, WhatsApp is used as fallback regardless of preference
|
||||
const canSendWA = !!user.phoneNumber && ((pref === 'whatsapp' || pref === 'both') || noValidEmail);
|
||||
// Only block if neither channel can deliver
|
||||
if (noValidEmail && !canSendWA) {
|
||||
return res.json({ message: 'No valid contact details on file — ticket delivery skipped.' });
|
||||
}
|
||||
|
||||
let tickets;
|
||||
if (registrationId) {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId, userId }
|
||||
});
|
||||
if (!registration) { res.status(404); throw new Error('Registration not found or does not belong to you'); }
|
||||
const registrationOptions = await prisma.registrationOption.findMany({ where: { registrationId } });
|
||||
tickets = await prisma.ticket.findMany({
|
||||
where: { registrationOptionId: { in: registrationOptions.map(o => o.id) }, userId },
|
||||
include: { event: true, registrationOption: { include: { eventOption: true, variant: true } }, usages: true },
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
} else {
|
||||
tickets = await prisma.ticket.findMany({
|
||||
where: { id: { in: ticketIds }, userId },
|
||||
include: { event: true, registrationOption: { include: { eventOption: true, variant: true } }, usages: true },
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
// Dedup by registrationOptionId
|
||||
{
|
||||
const seen = new Map(); const deduped = [];
|
||||
for (const t of tickets) {
|
||||
const key = t.registrationOptionId;
|
||||
if (!seen.has(key)) { seen.set(key, t); deduped.push(t); }
|
||||
else {
|
||||
const existing = seen.get(key);
|
||||
if ((existing.usages||[]).length === 0 && (t.usages||[]).length > 0) {
|
||||
seen.set(key, t); deduped[deduped.indexOf(existing)] = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
tickets = deduped;
|
||||
}
|
||||
if (tickets.length === 0) { res.status(404); throw new Error('No valid tickets found'); }
|
||||
|
||||
// Generate PDF
|
||||
const PDFDocument = require('pdfkit');
|
||||
const QRCode = require('qrcode');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const tempFilePath = path.join(__dirname, '..', '..', 'temp', `tickets-${userId}-${Date.now()}.pdf`);
|
||||
const tempDir = path.join(__dirname, '..', '..', 'temp');
|
||||
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 20 });
|
||||
const writeStream = fs.createWriteStream(tempFilePath);
|
||||
doc.pipe(writeStream);
|
||||
const pageWidth = doc.page.width - 40; const pageHeight = doc.page.height - 40;
|
||||
const ticketsPerRow = 2; const ticketsPerColumn = 4;
|
||||
const ticketWidth = pageWidth / ticketsPerRow; const ticketHeight = pageHeight / ticketsPerColumn;
|
||||
const qrCodes = await Promise.all(tickets.map(ticket => new Promise((resolve, reject) => {
|
||||
QRCode.toDataURL(ticket.qrCode, (err, url) => err ? reject(err) : resolve({ ticketId: ticket.id, qrDataUrl: url }));
|
||||
})));
|
||||
const qrCodeMap = qrCodes.reduce((map, item) => { map[item.ticketId] = item.qrDataUrl; return map; }, {});
|
||||
let ticketIndex = 0;
|
||||
for (const ticket of tickets) {
|
||||
const row = Math.floor(ticketIndex % ticketsPerColumn);
|
||||
const col = Math.floor((ticketIndex / ticketsPerColumn) % ticketsPerRow);
|
||||
const x = col * ticketWidth + 20; const y = row * ticketHeight + 20;
|
||||
doc.rect(x, y, ticketWidth, ticketHeight).stroke();
|
||||
doc.font('Helvetica-Bold').fontSize(12).text(ticket.event.title, x + 10, y + 10, { width: ticketWidth - 20 });
|
||||
doc.font('Helvetica').fontSize(10);
|
||||
const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : '';
|
||||
doc.text(`Ticket Type: ${ticket.registrationOption.eventOption.name}${variantSuffix}`, x + 10, y + 30, { width: ticketWidth - 20 });
|
||||
doc.text(`Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 });
|
||||
const eventDate = new Date(ticket.event.startDate);
|
||||
doc.text(`Date: ${new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate)}`, x + 10, y + 60, { width: ticketWidth - 20 });
|
||||
doc.text(`Purchased by: ${user.name}`, x + 10, y + 75, { width: ticketWidth - 20 });
|
||||
const qrCodeSize = Math.min(ticketWidth, ticketHeight) * 0.45;
|
||||
const qrX = x + (ticketWidth - qrCodeSize) / 2; const qrY = y + 90;
|
||||
doc.image(qrCodeMap[ticket.id], qrX, qrY, { width: qrCodeSize, height: qrCodeSize });
|
||||
doc.fontSize(8).text(`Ticket ID: ${ticket.id}`, x + 10, qrY + qrCodeSize + 5, { width: ticketWidth - 20, align: 'center' });
|
||||
ticketIndex++;
|
||||
if (ticketIndex % (ticketsPerRow * ticketsPerColumn) === 0 && ticketIndex < tickets.length) doc.addPage();
|
||||
}
|
||||
doc.end();
|
||||
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
|
||||
|
||||
const eventTitle = tickets[0].event.title;
|
||||
const eventDate = tickets[0].event.startDate
|
||||
? new Date(tickets[0].event.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: '';
|
||||
const pdfFilename = `tickets-${eventTitle.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
|
||||
const sentChannels = [];
|
||||
|
||||
const { sendMail, emailWrapper } = require('../utils/email');
|
||||
const { canWhatsApp, waPdf } = require('../utils/notify');
|
||||
const { buildWATicketCaption } = require('../utils/waMessages');
|
||||
const orgUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
|
||||
const ticketHtml = emailWrapper(`
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">🎟️ Your tickets are here!</p>
|
||||
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${user.name}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">Your tickets for <strong>${eventTitle}</strong>${eventDate ? ` on <strong>${eventDate}</strong>` : ''} are attached.</p>
|
||||
<p style="font-size:13px;color:#64748b;font-family:${ff}">Questions? Visit <a href="${orgUrl}" style="color:#2563eb">${orgUrl}</a>.</p>
|
||||
`, { preheader: `Your tickets for ${eventTitle} are attached!` });
|
||||
|
||||
// Send email unless preference is whatsapp-only
|
||||
if (pref !== 'whatsapp' && user.email && !user.email.endsWith('@deleted.invalid') && !user.email.endsWith('@guest.local')) {
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject: `Your tickets for ${eventTitle}`,
|
||||
html: ticketHtml,
|
||||
text: `Hi ${user.name},\n\nYour tickets for ${eventTitle}${eventDate ? ' on ' + eventDate : ''} are attached.\n\nSee you there!`,
|
||||
attachments: [{ filename: pdfFilename, path: tempFilePath, contentType: 'application/pdf' }],
|
||||
});
|
||||
sentChannels.push('email');
|
||||
}
|
||||
|
||||
// Send WhatsApp if preference includes it, or as fallback when email isn't available
|
||||
if (pref === 'whatsapp' || pref === 'both' || noValidEmail) {
|
||||
const { normalizeZAPhone } = require('../utils/whatsapp');
|
||||
const normalizedPhone = normalizeZAPhone(user.phoneNumber);
|
||||
if (normalizedPhone) {
|
||||
const caption = buildWATicketCaption({ name: user.name, eventTitle, eventDate });
|
||||
await waPdf({ ...user, phoneNumber: normalizedPhone, notificationPreference: 'both' }, tempFilePath, pdfFilename, caption).catch(() => {});
|
||||
sentChannels.push('whatsapp');
|
||||
}
|
||||
}
|
||||
|
||||
try { require('fs').unlinkSync(tempFilePath); } catch {}
|
||||
await prisma.ticket.updateMany({ where: { id: { in: tickets.map(t => t.id) } }, data: { emailSent: true, updatedAt: new Date() } });
|
||||
res.json({ message: `${tickets.length} ticket(s) sent via ${sentChannels.join(' & ') || 'no channel'}`, ticketIds: tickets.map(t => t.id) });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateTickets,
|
||||
getTickets,
|
||||
getUserTickets,
|
||||
getTicketById,
|
||||
getTicketByQrCode,
|
||||
getScanPreview,
|
||||
scanTicket,
|
||||
getTicketsByEvent,
|
||||
markTicketsAsEmailSent,
|
||||
emailTickets,
|
||||
sendTicketsTo,
|
||||
getRecentScans,
|
||||
getScanStats
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const multer = require('multer');
|
||||
|
||||
// Setup multer storage
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
const uploadPath = path.join(__dirname, '..', '..','public', 'uploads', 'events');
|
||||
|
||||
// Check if the upload location exists
|
||||
try {
|
||||
if (!fs.existsSync(uploadPath)) {
|
||||
console.log(`Upload directory does not exist. Creating: ${uploadPath}`);
|
||||
fs.mkdirSync(uploadPath, { recursive: true });
|
||||
} else {
|
||||
// Verify we have write permissions to the directory
|
||||
fs.accessSync(uploadPath, fs.constants.W_OK);
|
||||
console.log(`Upload directory exists and is writable: ${uploadPath}`);
|
||||
}
|
||||
cb(null, uploadPath);
|
||||
} catch (error) {
|
||||
console.error(`Error with upload directory: ${error.message}`);
|
||||
cb(new Error(`Cannot access upload directory: ${error.message}`));
|
||||
}
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
const uniqueName = `${Date.now()}-${file.originalname}`;
|
||||
cb(null, uniqueName);
|
||||
}
|
||||
});
|
||||
|
||||
// Multer middleware
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB max
|
||||
fileFilter: function (req, file, cb) {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(ext)) {
|
||||
return cb(new Error('Only images are allowed'), false);
|
||||
}
|
||||
cb(null, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Logo storage (separate subfolder)
|
||||
const logoStorage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
const uploadPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'branding');
|
||||
try {
|
||||
if (!fs.existsSync(uploadPath)) fs.mkdirSync(uploadPath, { recursive: true });
|
||||
cb(null, uploadPath);
|
||||
} catch (error) {
|
||||
cb(new Error(`Cannot access upload directory: ${error.message}`));
|
||||
}
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, `logo-${Date.now()}${path.extname(file.originalname).toLowerCase()}`);
|
||||
}
|
||||
});
|
||||
|
||||
const uploadLogo = multer({
|
||||
storage: logoStorage,
|
||||
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB
|
||||
fileFilter: function (req, file, cb) {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!['.jpg', '.jpeg', '.png', '.webp', '.svg'].includes(ext)) {
|
||||
return cb(new Error('Only image files are allowed'), false);
|
||||
}
|
||||
cb(null, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Controller function
|
||||
const uploadEventImage = (req, res) => {
|
||||
// Check for multer errors which would be passed in req.multerError
|
||||
if (req.multerError) {
|
||||
return res.status(500).json({ message: `Upload failed: ${req.multerError.message}` });
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const imageUrl = `/uploads/events/${req.file.filename}`;
|
||||
res.status(200).json({ url: imageUrl });
|
||||
};
|
||||
|
||||
const uploadLogoImage = (req, res) => {
|
||||
if (req.multerError) {
|
||||
return res.status(500).json({ message: `Upload failed: ${req.multerError.message}` });
|
||||
}
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'No file uploaded' });
|
||||
}
|
||||
res.status(200).json({ url: `/uploads/branding/${req.file.filename}` });
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
upload,
|
||||
uploadEventImage,
|
||||
uploadLogo,
|
||||
uploadLogoImage,
|
||||
};
|
||||
@@ -0,0 +1,934 @@
|
||||
const prisma = require('../config/db');
|
||||
const { generateToken, hashPassword, comparePassword } = require('../config/auth');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const axios = require('axios');
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Resolve a client IP from the request (works behind proxies)
|
||||
function getClientIp(req) {
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
if (forwarded) return forwarded.split(',')[0].trim();
|
||||
return req.socket?.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
const PRIVATE_IP_RE = /^(::1|::ffff:127\.|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/;
|
||||
|
||||
// Fire-and-forget: send a login notification email with approximate geo location
|
||||
async function sendLoginNotification(user, req) {
|
||||
try {
|
||||
const ip = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || 'Unknown device';
|
||||
const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' });
|
||||
|
||||
let location = 'Unknown location';
|
||||
if (ip !== 'unknown' && !PRIVATE_IP_RE.test(ip)) {
|
||||
try {
|
||||
const geo = await axios.get(
|
||||
`http://ip-api.com/json/${ip}?fields=status,city,regionName,country`,
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
if (geo.data?.status === 'success') {
|
||||
const parts = [geo.data.city, geo.data.regionName, geo.data.country].filter(Boolean);
|
||||
if (parts.length) location = parts.join(', ');
|
||||
}
|
||||
} catch { /* geo lookup failure is non-fatal */ }
|
||||
}
|
||||
|
||||
const { sendMail, buildLoginNotificationEmail } = require('../utils/email');
|
||||
const { buildWALogin } = require('../utils/waMessages');
|
||||
const content = buildLoginNotificationEmail({ name: user.name, when, location, userAgent });
|
||||
// Security: always email; also WhatsApp if preferred
|
||||
await sendMail({ to: user.email, subject: 'New login to your Hope Events account', ...content });
|
||||
const { waText } = require('../utils/notify');
|
||||
await waText(user, buildWALogin({ name: user.name, when, location, userAgent })).catch(() => {});
|
||||
} catch (e) {
|
||||
console.warn('[login notification] Failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget: send welcome email with next upcoming events
|
||||
async function sendWelcomeEmail(user) {
|
||||
try {
|
||||
const events = await prisma.event.findMany({
|
||||
where: { isActive: true, startDate: { gt: new Date() } },
|
||||
orderBy: { startDate: 'asc' },
|
||||
take: 3,
|
||||
select: { title: true, startDate: true },
|
||||
});
|
||||
const { sendMail, buildWelcomeEmail } = require('../utils/email');
|
||||
const { buildWAWelcome } = require('../utils/waMessages');
|
||||
const content = buildWelcomeEmail({ name: user.name, events });
|
||||
const { shouldEmail, waText } = require('../utils/notify');
|
||||
// Welcome is always sent via email; also via WhatsApp if preferred
|
||||
await sendMail({ to: user.email, subject: 'Welcome to Hope Events!', ...content });
|
||||
await waText(user, buildWAWelcome({ name: user.name, events })).catch(() => {});
|
||||
} catch (e) {
|
||||
console.warn('[welcome email] Failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// @desc Register a new user
|
||||
// @route POST /api/users
|
||||
// @access Public
|
||||
const registerUser = async (req, res) => {
|
||||
try {
|
||||
const { name, email, password, phoneNumber, notificationPreference } = req.body;
|
||||
|
||||
// Normalize phone using SA-aware normalisation
|
||||
const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp');
|
||||
const phone = normalizeZAPhone(phoneNumber) || (phoneNumber ? phoneNumber.replace(/\D/g, '') || null : null);
|
||||
|
||||
// Validate preference — WhatsApp requires a valid phone number
|
||||
const allowedPrefs = ['email', 'whatsapp', 'both'];
|
||||
let pref = allowedPrefs.includes(notificationPreference) ? notificationPreference : 'email';
|
||||
if ((pref === 'whatsapp' || pref === 'both') && !isValidZAPhone(phoneNumber)) {
|
||||
pref = 'email'; // silently fall back if no valid number
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const userExists = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ email },
|
||||
...(phone ? [{ phoneNumber: phone }] : [])
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (userExists) {
|
||||
res.status(400);
|
||||
throw new Error('User already exists');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
phoneNumber: phone,
|
||||
notificationPreference: pref,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
if (user) {
|
||||
// Send welcome email in the background — don't block the response
|
||||
sendWelcomeEmail(user).catch(() => {});
|
||||
|
||||
res.status(201).json({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
token: generateToken(user.id, user.role, user.tokenVersion)
|
||||
});
|
||||
} else {
|
||||
res.status(400);
|
||||
throw new Error('Invalid user data');
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Auth user & get token
|
||||
// @route POST /api/users/login
|
||||
// @access Public
|
||||
const loginUser = async (req, res) => {
|
||||
try {
|
||||
const {email, password} = req.body;
|
||||
|
||||
const rawInput = email;
|
||||
|
||||
// Normalize phone input — convert SA local format (0xx) → international (27xx)
|
||||
const { normalizeZAPhone } = require('../utils/whatsapp');
|
||||
const normalizedPhone = normalizeZAPhone(rawInput) || rawInput.replace(/\D/g, '');
|
||||
|
||||
// Check for user email or phone
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ email: rawInput },
|
||||
...(normalizedPhone ? [{ phoneNumber: normalizedPhone }] : []),
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(401);
|
||||
throw new Error('User does not exist');
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if (!user.isActive) {
|
||||
// If the account has a real email (not a guest placeholder), send an activation link via email
|
||||
if (user.email && !user.email.endsWith('@guest.local')) {
|
||||
try {
|
||||
const token = uuidv4();
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
|
||||
await prisma.passwordReset.updateMany({
|
||||
where: { userId: user.id, used: false },
|
||||
data: { used: true }
|
||||
});
|
||||
await prisma.passwordReset.create({
|
||||
data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false }
|
||||
});
|
||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
|
||||
const { sendMail, buildAccountActivationEmail } = require('../utils/email');
|
||||
const content = buildAccountActivationEmail({ name: user.name, activationUrl });
|
||||
sendMail({ to: user.email, subject: 'Activate your Hope Events account', ...content })
|
||||
.catch(e => console.warn('[activation email] Failed:', e?.message || e));
|
||||
} catch (e) {
|
||||
console.warn('[activation token] Failed to create activation token:', e?.message || e);
|
||||
}
|
||||
res.status(401);
|
||||
throw new Error('Your account is not yet active. We\'ve sent you an email with a link to activate your account.');
|
||||
}
|
||||
// No real email — if they have a phone number, send the activation link via WhatsApp
|
||||
if (user.phoneNumber) {
|
||||
try {
|
||||
const token = uuidv4();
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
|
||||
await prisma.passwordReset.updateMany({
|
||||
where: { userId: user.id, used: false },
|
||||
data: { used: true }
|
||||
});
|
||||
await prisma.passwordReset.create({
|
||||
data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false }
|
||||
});
|
||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
|
||||
const orgName = require('../utils/settingsCache').getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events');
|
||||
const waMessage = [
|
||||
`🔓 *Activate your ${orgName} account*`,
|
||||
'',
|
||||
`Hi ${user.name || 'there'},`,
|
||||
'',
|
||||
`Your account needs to be activated before you can log in. Tap the link below to set a password and activate your account:`,
|
||||
'',
|
||||
activationUrl,
|
||||
'',
|
||||
`_This link expires in 24 hours._`,
|
||||
].join('\n');
|
||||
const { waTextAny } = require('../utils/notify');
|
||||
waTextAny(user, waMessage).catch(e => console.warn('[activation WA] Failed:', e?.message || e));
|
||||
} catch (e) {
|
||||
console.warn('[activation token WA] Failed to create activation token:', e?.message || e);
|
||||
}
|
||||
res.status(401);
|
||||
throw new Error('Your account is not yet active. We\'ve sent you a WhatsApp message with a link to activate your account.');
|
||||
}
|
||||
res.status(401);
|
||||
throw new Error('Your account has been deactivated');
|
||||
}
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const LOCK_TIME = 5 * 60 * 1000; // 5 min
|
||||
|
||||
// Check lock
|
||||
if (user.lockUntil && user.lockUntil > new Date()) {
|
||||
const now = new Date().getTime();
|
||||
const lockTime = new Date(user.lockUntil).getTime();
|
||||
|
||||
const diffMs = lockTime - now;
|
||||
const diffMinutes = Math.ceil(diffMs / (1000 * 60));
|
||||
|
||||
throw new Error(`Too many attempts. Try again in ${diffMinutes} minute(s).`);
|
||||
}
|
||||
|
||||
// Check password
|
||||
const isMatch = await comparePassword(password, user.password);
|
||||
|
||||
if (!isMatch) {
|
||||
const attempts = user.failedAttempts + 1;
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failedAttempts: attempts,
|
||||
lockUntil:
|
||||
attempts >= MAX_ATTEMPTS
|
||||
? new Date(Date.now() + LOCK_TIME)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
|
||||
throw new Error("Invalid email or password");
|
||||
}
|
||||
|
||||
// ✅ SUCCESS → reset attempts
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { failedAttempts: 0, lockUntil: null },
|
||||
select: { id: true, name: true, email: true, role: true, tokenVersion: true, phoneNumber: true, notificationPreference: true },
|
||||
});
|
||||
|
||||
// Send login notification in the background
|
||||
sendLoginNotification(updated, req).catch(() => {});
|
||||
|
||||
res.json({
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
email: updated.email,
|
||||
role: updated.role,
|
||||
token: generateToken(updated.id, updated.role, updated.tokenVersion)
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get user profile
|
||||
// @route GET /api/users/profile
|
||||
// @access Private
|
||||
const getUserProfile = async (req, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
notificationPreference: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (user) {
|
||||
res.json(user);
|
||||
} else {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Update user profile
|
||||
// @route PUT /api/users/profile
|
||||
// @access Private
|
||||
const updateUserProfile = async (req, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user.id }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const { name, email, password, phoneNumber, currentPassword, notificationPreference } = req.body || {};
|
||||
|
||||
// If a password change is requested, verify current password for safety
|
||||
let newHashedPassword = undefined;
|
||||
if (typeof password === 'string' && password.trim().length > 0) {
|
||||
const newPw = password.trim();
|
||||
if (!currentPassword || typeof currentPassword !== 'string' || currentPassword.length === 0) {
|
||||
res.status(400);
|
||||
throw new Error('Current password is required to set a new password');
|
||||
}
|
||||
const matches = await comparePassword(currentPassword, user.password);
|
||||
if (!matches) {
|
||||
res.status(400);
|
||||
throw new Error('Current password is incorrect');
|
||||
}
|
||||
if (newPw.length < 8) {
|
||||
res.status(400);
|
||||
throw new Error('New password must be at least 8 characters long');
|
||||
}
|
||||
newHashedPassword = await hashPassword(newPw);
|
||||
}
|
||||
|
||||
// Normalize phone
|
||||
const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp');
|
||||
let newPhone = user.phoneNumber;
|
||||
if (phoneNumber !== undefined) {
|
||||
newPhone = phoneNumber ? (normalizeZAPhone(phoneNumber) || phoneNumber.replace(/\D/g, '') || null) : null;
|
||||
}
|
||||
|
||||
// Validate notification preference — WhatsApp requires a valid SA phone number
|
||||
const allowedPrefs = ['email', 'whatsapp', 'both'];
|
||||
let newPref = user.notificationPreference;
|
||||
if (notificationPreference !== undefined) {
|
||||
newPref = allowedPrefs.includes(notificationPreference) ? notificationPreference : user.notificationPreference;
|
||||
if ((newPref === 'whatsapp' || newPref === 'both') && !isValidZAPhone(newPhone)) {
|
||||
newPref = 'email';
|
||||
}
|
||||
}
|
||||
|
||||
// Update user data
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: {
|
||||
name: name || user.name,
|
||||
email: email || user.email,
|
||||
password: newHashedPassword ? newHashedPassword : user.password,
|
||||
phoneNumber: newPhone,
|
||||
notificationPreference: newPref,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
notificationPreference: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true,
|
||||
tokenVersion: true
|
||||
}
|
||||
});
|
||||
|
||||
// If the password was changed, send a security alert email (fire-and-forget)
|
||||
if (newHashedPassword) {
|
||||
const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
|
||||
const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail });
|
||||
sendMail({ to: updatedUser.email, subject: 'Your Hope Events password was changed', ...content })
|
||||
.catch(e => console.warn('[email] Failed to send password changed alert:', e?.message || e));
|
||||
const { waText } = require('../utils/notify');
|
||||
waText(updatedUser, content.text).catch(() => {});
|
||||
}
|
||||
|
||||
res.json({
|
||||
...updatedUser,
|
||||
token: generateToken(updatedUser.id, updatedUser.role, updatedUser.tokenVersion)
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get all users
|
||||
// @route GET /api/users
|
||||
// @access Private/Admin
|
||||
const getUsers = async (req, res) => {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 100));
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const select = {
|
||||
id: true, name: true, email: true, role: true,
|
||||
phoneNumber: true, notificationPreference: true, createdAt: true, updatedAt: true, isActive: true
|
||||
};
|
||||
|
||||
// Build filters
|
||||
const where = {};
|
||||
if (req.query.isActive !== undefined) {
|
||||
where.isActive = req.query.isActive === 'true';
|
||||
}
|
||||
if (req.query.role) {
|
||||
where.role = req.query.role;
|
||||
}
|
||||
if (req.query.search) {
|
||||
const s = req.query.search.trim();
|
||||
where.OR = [
|
||||
{ name: { contains: s, mode: 'insensitive' } },
|
||||
{ email: { contains: s, mode: 'insensitive' } },
|
||||
{ phoneNumber: { contains: s, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
const [users, total] = await prisma.$transaction([
|
||||
prisma.user.findMany({ where, select, orderBy: { name: 'asc' }, skip, take: limit }),
|
||||
prisma.user.count({ where })
|
||||
]);
|
||||
|
||||
res.json({ data: users, total, page, limit, pages: Math.ceil(total / limit) });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Check whether an account already exists for a given email and/or phone
|
||||
// @route GET /api/users/check-exists
|
||||
// @access Private/Supervisor
|
||||
const checkUserExists = async (req, res) => {
|
||||
try {
|
||||
const email = typeof req.query.email === 'string' ? req.query.email.trim() : '';
|
||||
const rawPhone = typeof req.query.phone === 'string' ? req.query.phone.trim() : '';
|
||||
|
||||
const { normalizeZAPhone } = require('../utils/whatsapp');
|
||||
const phone = normalizeZAPhone(rawPhone) || (rawPhone ? rawPhone.replace(/\D/g, '') : '');
|
||||
const phoneAlt = phone && phone.startsWith('27') ? '0' + phone.slice(2) : (phone && phone.length === 9 ? '27' + phone : null);
|
||||
|
||||
const searchClauses = [
|
||||
...(email ? [{ email }] : []),
|
||||
...(phone ? [{ phoneNumber: phone }] : []),
|
||||
...(phoneAlt ? [{ phoneNumber: phoneAlt }] : []),
|
||||
];
|
||||
|
||||
if (searchClauses.length === 0) {
|
||||
return res.json({ exists: false });
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: { OR: searchClauses },
|
||||
select: { email: true, phoneNumber: true },
|
||||
});
|
||||
|
||||
res.json({
|
||||
exists: !!existingUser,
|
||||
hasEmail: !!existingUser?.email && !existingUser.email.endsWith('@guest.local'),
|
||||
hasPhone: !!existingUser?.phoneNumber,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get user by ID
|
||||
// @route GET /api/users/:id
|
||||
// @access Private/Admin
|
||||
const getUserById = async (req, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.params.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (user) {
|
||||
res.json(user);
|
||||
} else {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Update user
|
||||
// @route PUT /api/users/:id
|
||||
// @access Private/Admin
|
||||
const updateUser = async (req, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.params.id }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const { name, email, role, isActive, phoneNumber, password } = req.body;
|
||||
|
||||
// Prepare data update, allow admin to set a new password
|
||||
const data = {
|
||||
name: name || user.name,
|
||||
email: email || user.email,
|
||||
role: role || user.role,
|
||||
isActive: isActive !== undefined ? isActive : user.isActive,
|
||||
phoneNumber: phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
if (password && typeof password === 'string' && password.trim().length > 0) {
|
||||
data.password = await hashPassword(password.trim());
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
res.json(updatedUser);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Delete user
|
||||
// @route DELETE /api/users/:id
|
||||
// @access Private/Admin
|
||||
const deleteUser = async (req, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.params.id }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// Instead of deleting, we deactivate the user
|
||||
await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
isActive: false,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ message: 'User deactivated' });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Anonymize user data (GDPR / "right to be forgotten")
|
||||
// @route POST /api/users/:id/anonymize
|
||||
// @access Private/Admin
|
||||
const anonymizeUser = async (req, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
|
||||
if (!user) { res.status(404); throw new Error('User not found'); }
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
name: 'Deleted User',
|
||||
email: `deleted-${req.params.id}@deleted.local`,
|
||||
phoneNumber: null,
|
||||
isActive: false,
|
||||
tokenVersion: { increment: 1 },
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ message: 'User data deleted' });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Request password reset
|
||||
// @route POST /api/users/forgot
|
||||
// @access Public
|
||||
const requestPasswordReset = async (req, res) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
if (!email || typeof email !== 'string') {
|
||||
res.status(400);
|
||||
throw new Error('Email is required');
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
|
||||
// Explicitly check existence as requested
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'Email not found' });
|
||||
}
|
||||
|
||||
// Create token valid for 1 hour
|
||||
const token = uuidv4();
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
|
||||
|
||||
// If PasswordReset model isn't available (migration not run), return clear error
|
||||
if (!prisma.passwordReset || typeof prisma.passwordReset.create !== 'function' || typeof prisma.passwordReset.updateMany !== 'function') {
|
||||
console.warn('[PasswordReset] Prisma model not available. Run Prisma migrations to enable password reset tokens.');
|
||||
return res.status(500).json({ message: 'Password reset is not available. Please contact support.' });
|
||||
}
|
||||
|
||||
// Invalidate previous tokens (optional)
|
||||
await prisma.passwordReset.updateMany({
|
||||
where: { userId: user.id, used: false, expiresAt: { gt: new Date() } },
|
||||
data: { used: true }
|
||||
});
|
||||
|
||||
await prisma.passwordReset.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
userId: user.id,
|
||||
token,
|
||||
expiresAt,
|
||||
used: false
|
||||
}
|
||||
});
|
||||
|
||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||
const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`;
|
||||
|
||||
const { sendMail, buildPasswordResetEmail } = require('../utils/email');
|
||||
const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl });
|
||||
// Security: always email
|
||||
sendMail({ to: user.email, subject: 'Reset your password', ...emailContent })
|
||||
.catch(e => console.warn('[password reset email] Failed:', e?.message || e));
|
||||
// Also WhatsApp if preferred (security message — sent in addition to email)
|
||||
const { waText } = require('../utils/notify');
|
||||
waText(user, emailContent.text).catch(() => {});
|
||||
|
||||
return res.json({ message: 'If that email exists, a password reset link has been sent.' });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Reset password using token
|
||||
// @route POST /api/users/reset
|
||||
// @access Public
|
||||
const resetPassword = async (req, res) => {
|
||||
try {
|
||||
const { token, password } = req.body;
|
||||
if (!token || !password) {
|
||||
res.status(400);
|
||||
throw new Error('Token and new password are required');
|
||||
}
|
||||
|
||||
// If PasswordReset model isn't available, avoid crashing and return a generic error
|
||||
if (!prisma.passwordReset || typeof prisma.passwordReset.findUnique !== 'function' || typeof prisma.passwordReset.update !== 'function') {
|
||||
res.status(400);
|
||||
throw new Error('Invalid or expired token');
|
||||
}
|
||||
|
||||
const reset = await prisma.passwordReset.findUnique({ where: { token } });
|
||||
if (!reset || reset.used || reset.expiresAt < new Date()) {
|
||||
res.status(400);
|
||||
throw new Error('Invalid or expired token');
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: reset.userId } });
|
||||
if (!user || !user.isActive) {
|
||||
res.status(400);
|
||||
throw new Error('User not found or inactive');
|
||||
}
|
||||
|
||||
const newHashed = await hashPassword(password);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({ where: { id: user.id }, data: { password: newHashed, updatedAt: new Date() } }),
|
||||
prisma.passwordReset.update({ where: { token }, data: { used: true } })
|
||||
]);
|
||||
|
||||
res.json({ message: 'Password has been reset successfully' });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Activate account using token (for inactive/guest accounts)
|
||||
// @route POST /api/users/activate
|
||||
// @access Public
|
||||
const activateAccount = async (req, res) => {
|
||||
try {
|
||||
const { token, password } = req.body;
|
||||
if (!token || !password) {
|
||||
res.status(400);
|
||||
throw new Error('Token and password are required');
|
||||
}
|
||||
if (typeof password !== 'string' || password.trim().length < 8) {
|
||||
res.status(400);
|
||||
throw new Error('Password must be at least 8 characters');
|
||||
}
|
||||
|
||||
if (!prisma.passwordReset || typeof prisma.passwordReset.findUnique !== 'function') {
|
||||
res.status(400);
|
||||
throw new Error('Invalid or expired token');
|
||||
}
|
||||
|
||||
const reset = await prisma.passwordReset.findUnique({ where: { token } });
|
||||
if (!reset || reset.used || reset.expiresAt < new Date()) {
|
||||
res.status(400);
|
||||
throw new Error('Invalid or expired activation link');
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: reset.userId } });
|
||||
if (!user) {
|
||||
res.status(400);
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const newHashed = await hashPassword(password.trim());
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: newHashed, isActive: true, updatedAt: new Date() }
|
||||
}),
|
||||
prisma.passwordReset.update({ where: { token }, data: { used: true } })
|
||||
]);
|
||||
|
||||
const updated = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { id: true, name: true, email: true, role: true, tokenVersion: true }
|
||||
});
|
||||
|
||||
// Send welcome email (skip guest/placeholder accounts)
|
||||
if (updated.email && !updated.email.endsWith('@guest.local')) {
|
||||
sendWelcomeEmail(updated).catch(() => {});
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Account activated successfully.',
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
email: updated.email,
|
||||
role: updated.role,
|
||||
token: generateToken(updated.id, updated.role, updated.tokenVersion)
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Revoke all active sessions for the logged-in user (increments tokenVersion)
|
||||
// @route POST /api/users/revoke-sessions
|
||||
// @access Private
|
||||
const revokeMySession = async (req, res) => {
|
||||
try {
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: { tokenVersion: { increment: 1 } },
|
||||
select: { id: true, role: true, tokenVersion: true },
|
||||
});
|
||||
// Bumping tokenVersion invalidates every existing token, including the one
|
||||
// this request just used. Issue a fresh token for the current device so it
|
||||
// isn't logged out too.
|
||||
res.json({
|
||||
message: 'All other sessions have been signed out.',
|
||||
token: generateToken(updated.id, updated.role, updated.tokenVersion),
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Admin: revoke all sessions for a specific user
|
||||
// @route POST /api/users/:id/revoke-sessions
|
||||
// @access Private/Admin
|
||||
const adminRevokeUserSessions = async (req, res) => {
|
||||
try {
|
||||
const target = await prisma.user.findUnique({ where: { id: req.params.id } });
|
||||
if (!target) {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
}
|
||||
await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: { tokenVersion: { increment: 1 } },
|
||||
});
|
||||
res.json({ message: `Sessions revoked for ${target.name}.` });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Close (and optionally anonymise) the logged-in user's own account
|
||||
// @route POST /api/users/close-account
|
||||
// @access Private
|
||||
const closeAccount = async (req, res) => {
|
||||
try {
|
||||
const { deleteData = false, password } = req.body || {};
|
||||
|
||||
// Re-verify password before allowing account closure
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
|
||||
if (!user) { res.status(404); throw new Error('User not found'); }
|
||||
|
||||
const passwordOk = await comparePassword(password, user.password);
|
||||
if (!passwordOk) {
|
||||
res.status(400);
|
||||
throw new Error('Incorrect password');
|
||||
}
|
||||
|
||||
// Capture real details before any anonymisation so the email goes to the right address
|
||||
const realEmail = user.email;
|
||||
const realName = user.name;
|
||||
|
||||
if (deleteData) {
|
||||
// Anonymise: wipe all personal data while keeping the row intact for referential integrity
|
||||
await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: {
|
||||
name: 'Deleted User',
|
||||
email: `deleted-${uuidv4()}@deleted.invalid`,
|
||||
password: '',
|
||||
phoneNumber: null,
|
||||
isActive: false,
|
||||
tokenVersion: { increment: 1 },
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
// Send closure confirmation to the real address (before it was wiped)
|
||||
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
|
||||
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
|
||||
const { waText } = require('../utils/notify');
|
||||
const { buildWAAccountClosed } = require('../utils/waMessages');
|
||||
const content = buildAccountClosedEmail({ name: realName, dataDeleted: true });
|
||||
sendMail({ to: realEmail, subject: 'Your Hope Events account has been closed', ...content }).catch(() => {});
|
||||
// WhatsApp while we still have phone (send before data wipe completes in-flight)
|
||||
waText(user, buildWAAccountClosed({ name: realName, dataDeleted: true })).catch(() => {});
|
||||
}
|
||||
return res.json({ message: 'Your account and personal data have been removed.' });
|
||||
} else {
|
||||
// Soft-deactivate only
|
||||
await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: {
|
||||
isActive: false,
|
||||
tokenVersion: { increment: 1 },
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
// Send closure confirmation
|
||||
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
|
||||
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
|
||||
const { waText } = require('../utils/notify');
|
||||
const { buildWAAccountClosed } = require('../utils/waMessages');
|
||||
const content = buildAccountClosedEmail({ name: realName, dataDeleted: false });
|
||||
sendMail({ to: realEmail, subject: 'Your Hope Events account has been closed', ...content }).catch(() => {});
|
||||
waText(user, buildWAAccountClosed({ name: realName, dataDeleted: false })).catch(() => {});
|
||||
}
|
||||
return res.json({ message: 'Your account has been deactivated.' });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
registerUser,
|
||||
loginUser,
|
||||
getUserProfile,
|
||||
updateUserProfile,
|
||||
getUsers,
|
||||
checkUserExists,
|
||||
getUserById,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
anonymizeUser,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
activateAccount,
|
||||
revokeMySession,
|
||||
adminRevokeUserSessions,
|
||||
closeAccount,
|
||||
};
|
||||
@@ -0,0 +1,572 @@
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
|
||||
|
||||
// @desc Handle Yoco webhook events
|
||||
// @route POST /api/webhooks/yoco
|
||||
// @access Public
|
||||
const handleYocoWebhook = async (req, res) => {
|
||||
try {
|
||||
const headers = req.headers;
|
||||
const requestBody = req.rawBody;
|
||||
|
||||
// Log every JSON request body for webhook controller
|
||||
try {
|
||||
const contentType = headers['content-type'] || headers['Content-Type'] || '';
|
||||
let parsedBody = null;
|
||||
if (typeof requestBody === 'string') {
|
||||
try {
|
||||
parsedBody = JSON.parse(requestBody);
|
||||
} catch (e) {
|
||||
// Not valid JSON; keep as raw string
|
||||
}
|
||||
} else if (requestBody && typeof requestBody === 'object') {
|
||||
parsedBody = requestBody;
|
||||
}
|
||||
} catch (logErr) {
|
||||
// Fail-safe: never block webhook processing due to logging issues
|
||||
console.error('Failed to log webhook JSON request:', logErr);
|
||||
}
|
||||
|
||||
// Verify webhook signature
|
||||
const id = headers['webhook-id'];
|
||||
const timestamp = headers['webhook-timestamp'];
|
||||
|
||||
const rawSigHeader = headers['webhook-signature'];
|
||||
if (!id || !timestamp || !rawSigHeader) {
|
||||
const responseObj = {
|
||||
success: false,
|
||||
message: 'Missing webhook headers',
|
||||
details: { missingHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'].filter(h => !headers[h]) }
|
||||
};
|
||||
return res.status(400).json(responseObj);
|
||||
}
|
||||
|
||||
// Reject replayed requests (Yoco recommends a 3-minute tolerance window)
|
||||
const webhookTimeMs = parseInt(timestamp, 10) * 1000;
|
||||
if (isNaN(webhookTimeMs) || Math.abs(Date.now() - webhookTimeMs) > 3 * 60 * 1000) {
|
||||
return res.status(400).json({ success: false, message: 'Webhook timestamp outside acceptable window' });
|
||||
}
|
||||
|
||||
// Construct the signed content
|
||||
const signedContent = `${id}.${timestamp}.${requestBody}`;
|
||||
|
||||
// Get the webhook secret from environment variables
|
||||
const secret = process.env.YOCO_WEBHOOK_SECRET;
|
||||
if (!secret) {
|
||||
console.error('YOCO_WEBHOOK_SECRET is not defined in environment variables');
|
||||
const responseObj = {
|
||||
success: false,
|
||||
message: 'Server configuration error',
|
||||
details: { error: 'Missing webhook secret configuration' }
|
||||
};
|
||||
return res.status(500).json(responseObj);
|
||||
}
|
||||
|
||||
const secretBytes = Buffer.from(secret.split('_')[1], "base64");
|
||||
|
||||
// Calculate expected signature
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', secretBytes)
|
||||
.update(signedContent)
|
||||
.digest('base64');
|
||||
|
||||
// Accept if any of the space-separated signatures match (supports key rotation)
|
||||
const signatures = rawSigHeader.split(' ').map(s => s.split(',')[1]).filter(Boolean);
|
||||
const signatureValid = signatures.some(sig => {
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(sig));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!signatureValid) {
|
||||
console.error('Invalid webhook signature');
|
||||
const responseObj = {
|
||||
success: false,
|
||||
message: 'Invalid signature',
|
||||
details: { error: 'Webhook signature verification failed' }
|
||||
};
|
||||
return res.status(403).json(responseObj);
|
||||
}
|
||||
|
||||
// Parse the webhook payload
|
||||
const webhookData = JSON.parse(requestBody);
|
||||
|
||||
// Extract checkoutId from payload or metadata
|
||||
const receivedCheckoutId = webhookData?.payload?.checkoutId || webhookData?.payload?.metadata?.checkoutId || webhookData?.payload?.metadata?.checkout_id;
|
||||
|
||||
// Optional filtering: allow only specific checkoutIds or prefixes for this endpoint
|
||||
try {
|
||||
const idsEnv = process.env.YOCO_ALLOWED_CHECKOUT_IDS || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_IDS || '';
|
||||
const prefixesEnv = process.env.YOCO_ALLOWED_CHECKOUT_PREFIXES || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_PREFIXES || '';
|
||||
const allowedIds = idsEnv.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const allowedPrefixes = prefixesEnv.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
const hasFilter = allowedIds.length > 0 || allowedPrefixes.length > 0;
|
||||
if (hasFilter) {
|
||||
const idMatches = receivedCheckoutId && (
|
||||
allowedIds.includes(receivedCheckoutId) ||
|
||||
allowedPrefixes.some(pref => receivedCheckoutId.startsWith(pref))
|
||||
);
|
||||
if (!idMatches) {
|
||||
// Store event for audit/unreconciled tracking
|
||||
try { await saveYocoTransaction(webhookData); } catch(e) { console.warn('Failed to store filtered yoco event:', e?.message || e); }
|
||||
// Acknowledge but ignore processing for unrelated checkout flows
|
||||
const responseObj = {
|
||||
success: true,
|
||||
message: 'Webhook acknowledged but ignored due to unmatched checkoutId for this endpoint',
|
||||
data: {
|
||||
filtered: true,
|
||||
receivedCheckoutId,
|
||||
allowedIdsCount: allowedIds.length,
|
||||
allowedPrefixesCount: allowedPrefixes.length
|
||||
}
|
||||
};
|
||||
return res.status(200).json(responseObj);
|
||||
}
|
||||
}
|
||||
} catch (filterErr) {
|
||||
// Never block webhook due to filter parsing errors; log only
|
||||
console.warn('Webhook checkoutId filter parse warning:', filterErr?.message || filterErr);
|
||||
}
|
||||
|
||||
// Automatic filtering by database registration record, with donation allowance
|
||||
try {
|
||||
if (receivedCheckoutId) {
|
||||
const existingRegistration = await prisma.registration.findFirst({
|
||||
where: { checkoutId: receivedCheckoutId },
|
||||
select: { id: true }
|
||||
});
|
||||
if (!existingRegistration) {
|
||||
// Allow donations (no registration) to proceed based on metadata
|
||||
const meta = webhookData?.payload?.metadata || {};
|
||||
const isDonation = meta?.type === 'donation' || !!meta?.isDonation || !!meta?.eventId;
|
||||
if (!isDonation) {
|
||||
try {
|
||||
await saveYocoTransaction(webhookData);
|
||||
} catch (saveErr) {
|
||||
console.warn('Failed to save unreconciled transaction:', saveErr?.message || saveErr);
|
||||
}
|
||||
const responseObj = {
|
||||
success: true,
|
||||
message: 'Webhook acknowledged and stored as unreconciled: no registration found for checkoutId',
|
||||
data: {
|
||||
filtered: true,
|
||||
reason: 'no_registration_for_checkoutId',
|
||||
receivedCheckoutId
|
||||
}
|
||||
};
|
||||
return res.status(200).json(responseObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (dbFilterErr) {
|
||||
// Do not fail webhook due to DB filter issues
|
||||
console.warn('Webhook DB auto-filter warning:', dbFilterErr?.message || dbFilterErr);
|
||||
}
|
||||
|
||||
let processedData = null;
|
||||
|
||||
// Process different event types
|
||||
switch (webhookData.type) {
|
||||
case 'payment.succeeded':
|
||||
processedData = await handlePaymentSucceeded(webhookData);
|
||||
break;
|
||||
// Add more event types as needed
|
||||
default:
|
||||
console.log(`Unhandled webhook event type: ${webhookData.type}`);
|
||||
try { await saveYocoTransaction(webhookData); } catch(e) { console.warn('Failed to store unhandled yoco event:', e?.message || e); }
|
||||
const responseObj = {
|
||||
success: true,
|
||||
message: 'Webhook received but event type not handled',
|
||||
data: {
|
||||
eventType: webhookData.type,
|
||||
webhookId: id,
|
||||
timestamp: timestamp
|
||||
}
|
||||
};
|
||||
return res.status(200).json(responseObj);
|
||||
}
|
||||
|
||||
// Return detailed success response
|
||||
const responseObj = {
|
||||
success: true,
|
||||
message: `Successfully processed ${webhookData.type} webhook`,
|
||||
data: {
|
||||
eventType: webhookData.type,
|
||||
webhookId: id,
|
||||
timestamp: timestamp,
|
||||
processedData
|
||||
}
|
||||
};
|
||||
return res.status(200).json(responseObj);
|
||||
} catch (error) {
|
||||
console.error('Webhook error:', error);
|
||||
const responseObj = {
|
||||
success: false,
|
||||
message: error.message,
|
||||
details: {
|
||||
error: error.stack,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
return res.status(500).json(responseObj);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle payment.succeeded event
|
||||
const handlePaymentSucceeded = async (webhookData) => {
|
||||
try {
|
||||
const paymentData = webhookData.payload;
|
||||
|
||||
// Log the payment data
|
||||
console.log('Payment succeeded:', paymentData);
|
||||
|
||||
// Extract payment details
|
||||
const {
|
||||
id: yocoPaymentId,
|
||||
amount,
|
||||
currency,
|
||||
status,
|
||||
metadata,
|
||||
paymentMethodDetails: method,
|
||||
checkoutId
|
||||
} = paymentData;
|
||||
|
||||
// Check if payment already exists to avoid duplicates
|
||||
const existingPayment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
externalId: yocoPaymentId
|
||||
}
|
||||
});
|
||||
|
||||
if (existingPayment) {
|
||||
console.log(`Payment ${yocoPaymentId} already processed`);
|
||||
try { await saveYocoTransaction(webhookData, { reconciled: true, paymentId: existingPayment.id }); } catch(e) { console.warn('Failed to reconcile YocoTransaction for existing payment:', e?.message || e); }
|
||||
return {
|
||||
status: 'already_processed',
|
||||
paymentId: existingPayment.id,
|
||||
externalId: yocoPaymentId
|
||||
};
|
||||
}
|
||||
|
||||
// Find the registration by checkoutId
|
||||
let registration = null;
|
||||
if (checkoutId) {
|
||||
registration = await prisma.registration.findFirst({
|
||||
where: {
|
||||
checkoutId: checkoutId
|
||||
},
|
||||
include: {
|
||||
event: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true
|
||||
}
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If no registration found by checkoutId, try metadata
|
||||
if (!registration && metadata && metadata.registrationId) {
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: {
|
||||
id: metadata.registrationId
|
||||
},
|
||||
include: {
|
||||
event: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true
|
||||
}
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve userId: registration owner → metadata → first active admin (never use a non-existent UUID)
|
||||
let resolvedUserId = registration?.userId || metadata?.userId || null;
|
||||
if (!resolvedUserId) {
|
||||
try {
|
||||
const adminUser = await prisma.user.findFirst({ where: { role: 'admin', isActive: true }, select: { id: true } });
|
||||
resolvedUserId = adminUser?.id || null;
|
||||
} catch (e) { /* ignore — payment will fail below if still null */ }
|
||||
}
|
||||
|
||||
// Create payment record
|
||||
const payment = await prisma.payment.create({
|
||||
data: {
|
||||
id: uuidv4(), // Generate a UUID for the payment
|
||||
amount: amount / 100, // Convert cents to your currency unit
|
||||
method: method.type, // The type of payment from the webhook
|
||||
status: status === 'succeeded' ? 'completed' : status,
|
||||
externalId: yocoPaymentId,
|
||||
registrationId: registration?.id || null,
|
||||
userId: resolvedUserId,
|
||||
eventId: registration?.eventId || metadata?.eventId || null,
|
||||
isDonation: !registration?.id
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
},
|
||||
registration: registration ? {
|
||||
include: {
|
||||
event: true
|
||||
}
|
||||
} : undefined,
|
||||
event: (registration?.eventId || metadata?.eventId) ? true : undefined
|
||||
}
|
||||
});
|
||||
|
||||
// If this payment is for a registration, update the registration status
|
||||
let updatedRegistration = null;
|
||||
let generatedTickets = [];
|
||||
|
||||
if (registration) {
|
||||
updatedRegistration = await updateRegistrationStatus(registration.id);
|
||||
|
||||
// If registration status is 'paid', tickets were generated
|
||||
if (updatedRegistration.status === 'paid') {
|
||||
// Find the generated tickets
|
||||
generatedTickets = await prisma.ticket.findMany({
|
||||
where: {
|
||||
registrationOption: {
|
||||
registrationId: registration.id
|
||||
}
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
qrCode: true,
|
||||
isUsed: true,
|
||||
registrationOptionId: true,
|
||||
eventId: true
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Payment ${yocoPaymentId} processed successfully`);
|
||||
|
||||
// Mark the Yoco transaction as reconciled and link the payment
|
||||
try { await saveYocoTransaction(webhookData, { reconciled: true, paymentId: payment.id }); } catch(e) { console.warn('Failed to reconcile YocoTransaction after payment create:', e?.message || e); }
|
||||
|
||||
// Send emails: payment confirmation first, then tickets (guarantees order)
|
||||
const _whPaymentId = payment.id;
|
||||
const _whUserId = registration?.userId;
|
||||
const _whRegId = registration?.id;
|
||||
const _whTicketsGenerated = generatedTickets.length > 0;
|
||||
(async () => {
|
||||
try {
|
||||
const { sendPaymentEmails } = require('../utils/notifications');
|
||||
await sendPaymentEmails(_whPaymentId);
|
||||
} catch (e) {
|
||||
console.error('Failed to send webhook payment emails:', e);
|
||||
}
|
||||
if (_whTicketsGenerated && _whUserId && _whRegId) {
|
||||
const mockReq = { user: { id: _whUserId }, body: { registrationId: _whRegId } };
|
||||
const mockRes = { status: () => mockRes, json: () => {} };
|
||||
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets from webhook:', e); }
|
||||
}
|
||||
})();
|
||||
|
||||
// Return processed data
|
||||
return {
|
||||
status: 'success',
|
||||
payment: {
|
||||
id: payment.id,
|
||||
externalId: yocoPaymentId,
|
||||
amount: payment.amount,
|
||||
method: payment.method,
|
||||
status: payment.status,
|
||||
createdAt: payment.createdAt
|
||||
},
|
||||
registration: registration ? {
|
||||
id: registration.id,
|
||||
status: updatedRegistration ? updatedRegistration.status : registration.status,
|
||||
eventId: registration.eventId,
|
||||
eventTitle: registration.event?.title,
|
||||
userId: registration.userId,
|
||||
userName: registration.user?.name
|
||||
} : null,
|
||||
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error processing payment.succeeded event:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Update registration status based on payments
|
||||
const updateRegistrationStatus = async (registrationId) => {
|
||||
try {
|
||||
// Refresh early-bird pricing before computing totalDue — ensures expired/exhausted tiers
|
||||
// are accounted for so we never mark a registration paid based on stale prices.
|
||||
try { await refreshPricingForRegistration(registrationId); } catch (e) {
|
||||
console.warn('[webhook] Price refresh failed:', e?.message);
|
||||
}
|
||||
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: {
|
||||
include: {
|
||||
eventOption: {
|
||||
include: {
|
||||
earlyBirdTiers: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
throw new Error(`Registration ${registrationId} not found`);
|
||||
}
|
||||
|
||||
// Calculate total amount paid
|
||||
const totalPaid = registration.payments.reduce((sum, payment) => sum + payment.amount, 0);
|
||||
|
||||
// Calculate total amount due (uses priceSnapshot — refreshed above)
|
||||
const totalDue = computeRegistrationTotalDue(registration, new Date());
|
||||
|
||||
// Update registration status based on payment
|
||||
let newStatus;
|
||||
if (totalPaid >= totalDue) {
|
||||
newStatus = 'paid';
|
||||
} else if (totalPaid > 0) {
|
||||
newStatus = 'partial_paid';
|
||||
} else {
|
||||
newStatus = 'pending';
|
||||
}
|
||||
|
||||
const updatedRegistration = await prisma.registration.update({
|
||||
where: { id: registrationId },
|
||||
data: {
|
||||
status: newStatus,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
// Generate tickets if status is "paid" (email handled by caller after payment email)
|
||||
let generatedTickets = [];
|
||||
if (newStatus === 'paid') {
|
||||
try {
|
||||
generatedTickets = await generateTicketsForRegistration(registrationId);
|
||||
} catch (error) {
|
||||
console.error('Error generating tickets:', error);
|
||||
// Don't throw the error, just log it - we still want to update the registration status
|
||||
}
|
||||
}
|
||||
|
||||
// Return the updated registration
|
||||
return {
|
||||
...updatedRegistration,
|
||||
totalPaid,
|
||||
totalDue,
|
||||
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error updating registration status:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleWhatsappWebhook = async (req, res) => {
|
||||
try {
|
||||
const { body } = req;
|
||||
const { message } = body;
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleYocoWebhook,
|
||||
handleWhatsappWebhook,
|
||||
};
|
||||
|
||||
// Helper: save or update Yoco transactions for any webhook event
|
||||
async function saveYocoTransaction(webhookData, updates = {}) {
|
||||
try {
|
||||
const payload = webhookData?.payload || {};
|
||||
const externalId = payload?.id || webhookData?.id;
|
||||
if (!externalId) {
|
||||
throw new Error('Missing external id in webhook payload');
|
||||
}
|
||||
|
||||
const amount = typeof payload?.amount === 'number' ? payload.amount : null; // cents if provided
|
||||
const currency = payload?.currency || null;
|
||||
const createdDateStr = payload?.createdDate || webhookData?.createdDate;
|
||||
const createdDate = createdDateStr ? new Date(createdDateStr) : null;
|
||||
const checkoutId = payload?.checkoutId || payload?.metadata?.checkoutId || payload?.metadata?.checkout_id || null;
|
||||
const methodType = payload?.paymentMethodDetails?.type || null;
|
||||
|
||||
// Try find existing
|
||||
const existing = await prisma.yocoTransaction.findFirst({ where: { externalId } });
|
||||
if (existing) {
|
||||
// Update with any provided updates
|
||||
if (updates && Object.keys(updates).length > 0) {
|
||||
return await prisma.yocoTransaction.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...updates, updatedAt: new Date() }
|
||||
});
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Create new
|
||||
const record = await prisma.yocoTransaction.create({
|
||||
data: {
|
||||
externalId,
|
||||
amount,
|
||||
currency,
|
||||
createdDate,
|
||||
checkoutId,
|
||||
methodType,
|
||||
reconciled: false,
|
||||
raw: webhookData,
|
||||
...updates
|
||||
}
|
||||
});
|
||||
return record;
|
||||
} catch (e) {
|
||||
// Unique constraint race: fetch existing
|
||||
if (e?.code === 'P2002') {
|
||||
const payload = webhookData?.payload || {};
|
||||
const externalId = payload?.id || webhookData?.id;
|
||||
const existing = await prisma.yocoTransaction.findFirst({ where: { externalId } });
|
||||
if (existing && updates && Object.keys(updates).length > 0) {
|
||||
return await prisma.yocoTransaction.update({ where: { id: existing.id }, data: { ...updates, updatedAt: new Date() } });
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
const prisma = require('../config/db');
|
||||
|
||||
function fmtDate(d) {
|
||||
try { return new Date(d).toLocaleString(); } catch { return String(d); }
|
||||
}
|
||||
|
||||
function getFrontendBaseUrl() {
|
||||
return String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function replacePlaceholders(str, ctx) {
|
||||
if (!str) return str;
|
||||
return String(str)
|
||||
.replace(/\{\{\s*name\s*\}\}/g, ctx.name || '')
|
||||
.replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '')
|
||||
.replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '')
|
||||
.replace(/\{\{\s*event\.(link|url)\s*\}\}/g, ctx.eventLink || '');
|
||||
}
|
||||
|
||||
function parseFreeformPhones(lines) {
|
||||
const recipients = [];
|
||||
const input = Array.isArray(lines) ? lines : String(lines || '').split(/\r?\n/);
|
||||
for (const raw of input) {
|
||||
const s = String(raw || '').trim();
|
||||
if (!s) continue;
|
||||
// Format: Name <phone> or just phone
|
||||
const m = s.match(/^(.*?)<\s*([+\d\s()-]+)\s*>\s*$/);
|
||||
if (m) {
|
||||
recipients.push({ phone: m[2].trim(), name: m[1].trim().replace(/^"|"$/g, '').trim() });
|
||||
} else {
|
||||
// Accept raw phone-like strings
|
||||
const digits = s.replace(/\D/g, '');
|
||||
if (digits.length >= 9) recipients.push({ phone: s, name: '' });
|
||||
}
|
||||
}
|
||||
return recipients;
|
||||
}
|
||||
|
||||
// @desc Preview WhatsApp broadcast recipients
|
||||
// @route POST /api/whatsapp-broadcasts/preview
|
||||
// @access Private/Supervisor or Admin
|
||||
const previewWhatsAppBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { userIds, phones, eventId } = req.body || {};
|
||||
const { normalizeZAPhone } = require('../utils/whatsapp');
|
||||
|
||||
const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : [];
|
||||
let users = [];
|
||||
if (ids.length) {
|
||||
users = await prisma.user.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, name: true, phoneNumber: true, notificationPreference: true }
|
||||
});
|
||||
}
|
||||
|
||||
const extra = parseFreeformPhones(phones);
|
||||
|
||||
const map = new Map();
|
||||
for (const u of users) {
|
||||
const phone = normalizeZAPhone(u.phoneNumber);
|
||||
if (!phone) continue;
|
||||
if (!map.has(phone)) map.set(phone, { phone, name: u.name || '' });
|
||||
}
|
||||
for (const r of extra) {
|
||||
const phone = normalizeZAPhone(r.phone);
|
||||
if (!phone) continue;
|
||||
if (!map.has(phone)) map.set(phone, { phone, name: r.name || '' });
|
||||
}
|
||||
const recipients = Array.from(map.values());
|
||||
|
||||
return res.json({ matched: recipients.length, recipients: recipients.slice(0, 20) });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Send WhatsApp broadcast now
|
||||
// @route POST /api/whatsapp-broadcasts/send
|
||||
// @access Private/Supervisor or Admin
|
||||
const sendWhatsAppBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { message, userIds, phones, eventId } = req.body || {};
|
||||
if (!message) {
|
||||
return res.status(400).json({ message: 'message is required' });
|
||||
}
|
||||
|
||||
const { normalizeZAPhone, sendText } = require('../utils/whatsapp');
|
||||
|
||||
const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : [];
|
||||
let users = [];
|
||||
if (ids.length) {
|
||||
users = await prisma.user.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, name: true, phoneNumber: true }
|
||||
});
|
||||
}
|
||||
const extra = parseFreeformPhones(phones);
|
||||
|
||||
const map = new Map();
|
||||
for (const u of users) {
|
||||
const phone = normalizeZAPhone(u.phoneNumber);
|
||||
if (!phone) continue;
|
||||
if (!map.has(phone)) map.set(phone, { phone, name: u.name || '' });
|
||||
}
|
||||
for (const r of extra) {
|
||||
const phone = normalizeZAPhone(r.phone);
|
||||
if (!phone) continue;
|
||||
if (!map.has(phone)) map.set(phone, { phone, name: r.name || '' });
|
||||
}
|
||||
const recipients = Array.from(map.values());
|
||||
if (recipients.length === 0) {
|
||||
return res.status(400).json({ message: 'No valid recipients with phone numbers' });
|
||||
}
|
||||
|
||||
let event = null;
|
||||
if (eventId && typeof eventId === 'string') {
|
||||
event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
}
|
||||
const eventTitle = event?.title || '';
|
||||
const eventStart = event?.startDate ? fmtDate(event.startDate) : '';
|
||||
const eventLink = event ? `${getFrontendBaseUrl()}/events/${encodeURIComponent(event.id)}` : '';
|
||||
|
||||
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
||||
const ctx = { name: rcpt.name || '', eventTitle, eventStart, eventLink };
|
||||
const finalMessage = replacePlaceholders(message, ctx);
|
||||
await sendText(rcpt.phone, finalMessage);
|
||||
}));
|
||||
|
||||
const sent = results.filter(r => r.status === 'fulfilled').length;
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
try { console.warn('[wa-broadcast] failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({ matched: recipients.length, sent });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Schedule a WhatsApp broadcast
|
||||
// @route POST /api/whatsapp-broadcasts/schedule
|
||||
// @access Private/Supervisor or Admin
|
||||
const scheduleWhatsAppBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { scheduledAt, message, userIds, phones, eventId } = req.body || {};
|
||||
if (!scheduledAt) return res.status(400).json({ message: 'scheduledAt is required' });
|
||||
const when = new Date(scheduledAt);
|
||||
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
|
||||
if (!message) return res.status(400).json({ message: 'message is required' });
|
||||
|
||||
const payload = { message, userIds, phones, eventId };
|
||||
|
||||
const { addJob } = require('../utils/scheduledEmails');
|
||||
const created = addJob({
|
||||
broadcast: true,
|
||||
channel: 'whatsapp',
|
||||
scheduledAt: when.toISOString(),
|
||||
createdById: req.user?.id || null,
|
||||
payload,
|
||||
});
|
||||
|
||||
return res.status(201).json({ message: 'WhatsApp broadcast scheduled', job: created });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { previewWhatsAppBroadcast, sendWhatsAppBroadcast, scheduleWhatsAppBroadcast };
|
||||
@@ -0,0 +1,224 @@
|
||||
const {
|
||||
getConfig, setConfig,
|
||||
getStatus, startSession, restartSession, logoutSession,
|
||||
getQr, requestPairingCode,
|
||||
createInstance, deleteInstance,
|
||||
} = require('../utils/whatsapp');
|
||||
const { sendMail } = require('../utils/email');
|
||||
|
||||
// ─── Config management ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/whatsapp/config
|
||||
* Returns the current WAWP credentials (token is masked).
|
||||
*/
|
||||
const getConfigHandler = async (req, res) => {
|
||||
try {
|
||||
const { token, instanceId } = await getConfig();
|
||||
// Mask the token for display — show first 4 chars + asterisks
|
||||
const maskedToken = token
|
||||
? token.slice(0, 4) + '*'.repeat(Math.max(0, token.length - 4))
|
||||
: '';
|
||||
res.json({
|
||||
tokenMasked: maskedToken,
|
||||
instanceId: instanceId || '',
|
||||
hasToken: !!token,
|
||||
hasInstance: !!instanceId,
|
||||
configured: !!(token && instanceId),
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.message || 'Failed to load config' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/whatsapp/config
|
||||
* Body: { token, instanceId }
|
||||
* Saves credentials to DB; instanceId is optional (keep existing if omitted).
|
||||
*/
|
||||
const saveConfigHandler = async (req, res) => {
|
||||
try {
|
||||
const { token, instanceId } = req.body || {};
|
||||
|
||||
const current = await getConfig();
|
||||
|
||||
// Resolve token: '_clear_' resets it; blank/absent keeps existing
|
||||
let resolvedToken = current.token;
|
||||
if (token === '_clear_') {
|
||||
resolvedToken = '';
|
||||
} else if (token && token.trim()) {
|
||||
resolvedToken = token.trim();
|
||||
}
|
||||
|
||||
// Resolve instanceId: blank/absent keeps existing
|
||||
const resolvedInstanceId = (instanceId && instanceId.trim())
|
||||
? instanceId.trim()
|
||||
: current.instanceId || '';
|
||||
|
||||
await setConfig(resolvedToken, resolvedInstanceId);
|
||||
res.json({ message: 'WAWP configuration saved successfully.' });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.message || 'Failed to save config' });
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Instance lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/whatsapp/create-instance
|
||||
* Body: { name? }
|
||||
* Creates a new WAWP session instance and saves its id to the DB.
|
||||
*/
|
||||
const createInstanceHandler = async (req, res) => {
|
||||
try {
|
||||
const { name } = req.body || {};
|
||||
const data = await createInstance(name);
|
||||
res.json({ message: 'Instance created and saved.', ...data });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/whatsapp/delete-instance
|
||||
* Deletes the current WAWP instance and clears it from the DB.
|
||||
*/
|
||||
const deleteInstanceHandler = async (req, res) => {
|
||||
try {
|
||||
const data = await deleteInstance();
|
||||
res.json({ message: 'Instance deleted.', ...data });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Admin session endpoints ──────────────────────────────────────────────────
|
||||
|
||||
const getStatusHandler = async (req, res) => {
|
||||
try {
|
||||
res.json(await getStatus());
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const getQrHandler = async (req, res) => {
|
||||
try {
|
||||
const data = await getQr();
|
||||
// Normalise: strip leading "data:image/png;base64," if WAWP already includes it,
|
||||
// so the client always receives a clean base64 string it can prefix itself.
|
||||
if (data?.qr) {
|
||||
data.qr = data.qr.replace(/^data:image\/png;base64,/, '');
|
||||
}
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const requestCodeHandler = async (req, res) => {
|
||||
try {
|
||||
const { phoneNumber } = req.body || {};
|
||||
if (!phoneNumber) return res.status(400).json({ message: 'phoneNumber is required' });
|
||||
res.json(await requestPairingCode(phoneNumber));
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const logoutHandler = async (req, res) => {
|
||||
try {
|
||||
res.json(await logoutSession());
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const startHandler = async (req, res) => {
|
||||
try {
|
||||
res.json(await startSession());
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const restartHandler = async (req, res) => {
|
||||
try {
|
||||
res.json(await restartSession());
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Webhook — auto-recovery ──────────────────────────────────────────────────
|
||||
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const RETRY_DELAYS = [5_000, 15_000, 30_000]; // ms between each attempt
|
||||
const STATUS_WAIT_MS = 10_000; // wait after restart before checking
|
||||
|
||||
const handleWebhook = async (req, res) => {
|
||||
// Acknowledge immediately so WAWP doesn't time out
|
||||
res.status(200).json({ ok: true });
|
||||
|
||||
try {
|
||||
const { event, session } = req.body || {};
|
||||
if (event !== 'session.status') return;
|
||||
|
||||
const status = session?.status;
|
||||
// Only auto-recover on FAILED — STOPPED may be intentional
|
||||
if (status !== 'FAILED') return;
|
||||
|
||||
console.warn('[whatsapp webhook] Session FAILED — starting recovery...');
|
||||
|
||||
let recovered = false;
|
||||
|
||||
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
||||
await new Promise(r => setTimeout(r, RETRY_DELAYS[i]));
|
||||
try {
|
||||
await restartSession();
|
||||
await new Promise(r => setTimeout(r, STATUS_WAIT_MS));
|
||||
const info = await getStatus();
|
||||
const s = info?.status;
|
||||
if (s === 'WORKING' || s === 'SCAN_QR_CODE' || s === 'STARTING') {
|
||||
recovered = true;
|
||||
console.info(`[whatsapp webhook] Session recovered on attempt ${i + 1} (status: ${s})`);
|
||||
break;
|
||||
}
|
||||
console.warn(`[whatsapp webhook] Attempt ${i + 1}: status still ${s}`);
|
||||
} catch (e) {
|
||||
console.warn(`[whatsapp webhook] Attempt ${i + 1} error:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!recovered) {
|
||||
console.error(`[whatsapp webhook] Could not recover after ${MAX_ATTEMPTS} attempts — sending admin alert`);
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
const adminEmail = getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || '')
|
||||
|| getSettingSync('org_email', process.env.EMAIL_FROM || process.env.EMAIL_USER || '');
|
||||
const dashboardUrl = `${(process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '')}/dashboard/admin/whatsapp`;
|
||||
const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' });
|
||||
await sendMail({
|
||||
to: adminEmail,
|
||||
subject: 'WhatsApp session is down — action required',
|
||||
text: `The Hope Events WhatsApp session has failed and could not be automatically recovered.\n\nTime: ${when}\n\nPlease visit the admin dashboard to reconnect:\n${dashboardUrl}`,
|
||||
html: `<p>The Hope Events WhatsApp session has failed and could not be automatically recovered after ${MAX_ATTEMPTS} attempts.</p><p><strong>Time:</strong> ${when}</p><p>Please <a href="${dashboardUrl}">visit the admin dashboard</a> to re-scan the QR code and reconnect.</p>`,
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[whatsapp webhook] Unhandled error in recovery handler:', e.message);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getConfigHandler,
|
||||
saveConfigHandler,
|
||||
createInstanceHandler,
|
||||
deleteInstanceHandler,
|
||||
getStatusHandler,
|
||||
getQrHandler,
|
||||
requestCodeHandler,
|
||||
logoutHandler,
|
||||
startHandler,
|
||||
restartHandler,
|
||||
handleWebhook,
|
||||
};
|
||||
@@ -0,0 +1,272 @@
|
||||
const prisma = require('../config/db');
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
|
||||
function getYocoModel() {
|
||||
// Gracefully handle environments where the Prisma client hasn't been regenerated
|
||||
// and YocoTransaction model is not available yet.
|
||||
return prisma && prisma.yocoTransaction ? prisma.yocoTransaction : null;
|
||||
}
|
||||
|
||||
// @desc Get all Yoco transactions (optionally filter by reconciled and ignored)
|
||||
// @route GET /api/yoco-transactions
|
||||
// @access Private/Admin or Supervisor
|
||||
const getAllYocoTransactions = async (req, res) => {
|
||||
try {
|
||||
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
|
||||
return res.status(403).json({ success: false, message: 'Forbidden' });
|
||||
}
|
||||
|
||||
const { reconciled, ignored } = req.query;
|
||||
const where = {};
|
||||
if (typeof reconciled !== 'undefined') where.reconciled = String(reconciled) === 'true';
|
||||
if (typeof ignored !== 'undefined') where.ignored = String(ignored) === 'true';
|
||||
|
||||
const Yoco = getYocoModel();
|
||||
if (!Yoco) {
|
||||
console.warn('YocoTransaction model not available on Prisma client. Did you run migrations and `prisma generate`?');
|
||||
return res.status(200).json({ success: true, data: [], message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
|
||||
}
|
||||
|
||||
const items = await Yoco.findMany({
|
||||
where,
|
||||
orderBy: [{ createdDate: 'desc' }, { createdAt: 'desc' }]
|
||||
});
|
||||
|
||||
return res.status(200).json({ success: true, data: items });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Yoco transactions:', error);
|
||||
return res.status(500).json({ success: false, message: 'Server error', details: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get unreconciled Yoco transactions (not reconciled and not ignored)
|
||||
// @route GET /api/yoco-transactions/unreconciled
|
||||
// @access Private/Admin or Supervisor
|
||||
const getUnreconciledYocoTransactions = async (req, res) => {
|
||||
try {
|
||||
// Basic role check if auth middleware sets req.user
|
||||
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
|
||||
return res.status(403).json({ success: false, message: 'Forbidden' });
|
||||
}
|
||||
|
||||
const Yoco = getYocoModel();
|
||||
if (!Yoco) {
|
||||
console.warn('YocoTransaction model not available on Prisma client. Did you run migrations and `prisma generate`?');
|
||||
return res.status(200).json({ success: true, data: [], message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
|
||||
}
|
||||
|
||||
const items = await Yoco.findMany({
|
||||
where: { reconciled: false, ignored: false },
|
||||
orderBy: { createdDate: 'desc' }
|
||||
});
|
||||
|
||||
return res.status(200).json({ success: true, data: items });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch unreconciled Yoco transactions:', error);
|
||||
return res.status(500).json({ success: false, message: 'Server error', details: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Reconcile a Yoco transaction to a Registration or Donation (creates Payment)
|
||||
// @route POST /api/yoco-transactions/:id/reconcile
|
||||
// @access Private/Admin or Supervisor
|
||||
const reconcileYocoTransaction = async (req, res) => {
|
||||
try {
|
||||
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
|
||||
return res.status(403).json({ success: false, message: 'Forbidden' });
|
||||
}
|
||||
|
||||
const Yoco = getYocoModel();
|
||||
if (!Yoco) {
|
||||
return res.status(400).json({ success: false, message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { registrationId, eventId } = req.body || {};
|
||||
|
||||
const ytx = await Yoco.findUnique({ where: { id } });
|
||||
if (!ytx) return res.status(404).json({ success: false, message: 'YocoTransaction not found' });
|
||||
if (ytx.reconciled && ytx.paymentId) {
|
||||
return res.status(409).json({ success: false, message: 'Already reconciled', data: ytx });
|
||||
}
|
||||
|
||||
// Load registration if provided
|
||||
let registration = null;
|
||||
if (registrationId) {
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
event: true,
|
||||
user: true,
|
||||
registrationOptions: { include: { eventOption: true } },
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
if (!registration) return res.status(404).json({ success: false, message: 'Registration not found' });
|
||||
}
|
||||
|
||||
// Determine payment fields
|
||||
const amountFloat = typeof ytx.amount === 'number' ? (ytx.amount / 100) : 0;
|
||||
if (!amountFloat || amountFloat <= 0) {
|
||||
return res.status(400).json({ success: false, message: 'Invalid amount on YocoTransaction for reconciliation' });
|
||||
}
|
||||
|
||||
const externalId = ytx.externalId || undefined;
|
||||
|
||||
// Resolve a valid userId for the Payment to satisfy FK constraints
|
||||
let resolvedUserId = null;
|
||||
if (registration?.userId) {
|
||||
resolvedUserId = registration.userId;
|
||||
} else if (ytx?.raw?.payload?.metadata?.userId) {
|
||||
const metaUserId = String(ytx.raw.payload.metadata.userId);
|
||||
try {
|
||||
const exists = await prisma.user.findUnique({ where: { id: metaUserId } });
|
||||
if (exists) resolvedUserId = metaUserId;
|
||||
} catch {}
|
||||
}
|
||||
if (!resolvedUserId && req.user?.id) {
|
||||
// Fallback to the acting supervisor/admin to avoid FK violations
|
||||
resolvedUserId = req.user.id;
|
||||
}
|
||||
if (!resolvedUserId) {
|
||||
return res.status(400).json({ success: false, message: 'Unable to resolve a valid user for this payment' });
|
||||
}
|
||||
|
||||
// Build payment data
|
||||
const paymentData = {
|
||||
amount: amountFloat,
|
||||
method: ytx.methodType || 'card',
|
||||
userId: resolvedUserId,
|
||||
registrationId: registration?.id || null,
|
||||
eventId: registration?.eventId || eventId || null,
|
||||
isDonation: !registration?.id,
|
||||
externalId: externalId,
|
||||
status: 'completed'
|
||||
};
|
||||
|
||||
// Ensure donation has an eventId
|
||||
if (!registration && !paymentData.eventId) {
|
||||
return res.status(400).json({ success: false, message: 'eventId is required when reconciling as donation' });
|
||||
}
|
||||
|
||||
// Create payment
|
||||
// Preserve original Yoco creation time to correctly evaluate early-bird pricing
|
||||
const createdAt = ytx.createdDate || ytx.createdAt || new Date();
|
||||
const payment = await prisma.payment.create({ data: { ...paymentData, createdAt } });
|
||||
|
||||
// Optionally update registration status when applicable and generate/email tickets if paid
|
||||
let generatedTickets = [];
|
||||
if (registration) {
|
||||
try {
|
||||
const updatedReg = await updateRegistrationStatus(registration.id);
|
||||
if (updatedReg && updatedReg.status === 'paid') {
|
||||
try {
|
||||
generatedTickets = await generateTicketsForRegistration(registration.id);
|
||||
if (Array.isArray(generatedTickets) && generatedTickets.length > 0) {
|
||||
try {
|
||||
const mockReq = { user: { id: registration.userId }, body: { registrationId: registration.id } };
|
||||
const mockRes = { status: () => mockRes, json: () => {} };
|
||||
await emailTickets(mockReq, mockRes);
|
||||
} catch (emailErr) {
|
||||
console.error('Error emailing tickets after Yoco reconciliation:', emailErr);
|
||||
}
|
||||
}
|
||||
} catch (genErr) {
|
||||
console.error('Error generating tickets after Yoco reconciliation:', genErr);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// log but do not fail reconciliation
|
||||
console.warn('Failed to update registration after reconciliation:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// Send emails for the reconciled payment
|
||||
try {
|
||||
const { sendPaymentEmails } = require('../utils/notifications');
|
||||
await sendPaymentEmails(payment.id);
|
||||
} catch (e) {
|
||||
console.error('Failed to send payment emails after reconciliation:', e);
|
||||
}
|
||||
|
||||
// Update yoco transaction as reconciled
|
||||
const updatedTx = await Yoco.update({
|
||||
where: { id: ytx.id },
|
||||
data: { reconciled: true, paymentId: payment.id }
|
||||
});
|
||||
|
||||
return res.status(200).json({ success: true, data: { yocoTransaction: updatedTx, payment, generatedTickets: (generatedTickets && generatedTickets.length) ? generatedTickets : undefined } });
|
||||
} catch (error) {
|
||||
console.error('Failed to reconcile Yoco transaction:', error);
|
||||
// Handle unique externalId conflicts (if a Payment with same externalId already exists)
|
||||
if (error?.code === 'P2002') {
|
||||
try {
|
||||
const existing = await prisma.payment.findFirst({ where: { externalId: (await getYocoModel()?.findUnique({ where: { id: req.params.id } }))?.externalId } });
|
||||
if (existing) {
|
||||
const updatedTx = await getYocoModel()?.update({ where: { id: req.params.id }, data: { reconciled: true, paymentId: existing.id } });
|
||||
if (updatedTx) {
|
||||
return res.status(200).json({ success: true, data: { yocoTransaction: updatedTx, payment: existing }, message: 'Linked to existing payment' });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// Minimal registration status update mirroring webhook logic
|
||||
async function updateRegistrationStatus(registrationId) {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
if (!registration) return null;
|
||||
const totalPaid = (registration.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const totalDue = require('../utils/pricing').computeRegistrationTotalDue(registration, new Date());
|
||||
let newStatus = 'pending';
|
||||
if (totalPaid >= totalDue) newStatus = 'paid';
|
||||
else if (totalPaid > 0) newStatus = 'partial_paid';
|
||||
return prisma.registration.update({ where: { id: registrationId }, data: { status: newStatus, updatedAt: new Date() } });
|
||||
}
|
||||
|
||||
// @desc Ignore a Yoco transaction (mark as ignored and reconciled without creating a payment)
|
||||
// @route POST /api/yoco-transactions/:id/ignore
|
||||
// @access Private/Admin or Supervisor
|
||||
const ignoreYocoTransaction = async (req, res) => {
|
||||
try {
|
||||
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
|
||||
return res.status(403).json({ success: false, message: 'Forbidden' });
|
||||
}
|
||||
const Yoco = getYocoModel();
|
||||
if (!Yoco) {
|
||||
return res.status(400).json({ success: false, message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
|
||||
}
|
||||
const { id } = req.params;
|
||||
const ytx = await Yoco.findUnique({ where: { id } });
|
||||
if (!ytx) return res.status(404).json({ success: false, message: 'YocoTransaction not found' });
|
||||
|
||||
if (ytx.reconciled && ytx.ignored) {
|
||||
return res.status(200).json({ success: true, data: ytx, message: 'Already ignored' });
|
||||
}
|
||||
|
||||
const updated = await Yoco.update({
|
||||
where: { id },
|
||||
data: { ignored: true, reconciled: true }
|
||||
});
|
||||
return res.status(200).json({ success: true, data: updated });
|
||||
} catch (error) {
|
||||
console.error('Failed to ignore Yoco transaction:', error);
|
||||
return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getAllYocoTransactions,
|
||||
getUnreconciledYocoTransactions,
|
||||
reconcileYocoTransaction,
|
||||
ignoreYocoTransaction
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
const rateLimit = require("express-rate-limit");
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const prisma = require('../config/db');
|
||||
|
||||
// Protect routes - verify token
|
||||
const protect = async (req, res, next) => {
|
||||
let token;
|
||||
|
||||
// Check if token exists in headers
|
||||
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
|
||||
try {
|
||||
// Get token from header
|
||||
token = req.headers.authorization.split(' ')[1];
|
||||
|
||||
// Verify token
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Get user from the token (exclude password)
|
||||
req.user = await prisma.user.findUnique({
|
||||
where: { id: decoded.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
phoneNumber: true,
|
||||
tokenVersion: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!req.user) {
|
||||
res.status(401);
|
||||
return next(new Error('User not found'));
|
||||
}
|
||||
|
||||
if (!req.user.isActive) {
|
||||
res.status(401);
|
||||
return next(new Error('User account is deactivated'));
|
||||
}
|
||||
|
||||
// Revocation check — tokenVersion in JWT must match DB
|
||||
// Old tokens without tokenVersion are treated as version 0
|
||||
const tokenVer = decoded.tokenVersion ?? 0;
|
||||
if (tokenVer !== req.user.tokenVersion) {
|
||||
res.status(401);
|
||||
return next(new Error('Session has been revoked. Please log in again.'));
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(401);
|
||||
return next(new Error('Not authorized, token failed'));
|
||||
}
|
||||
} else {
|
||||
res.status(401);
|
||||
return next(new Error('Not authorized, no token'));
|
||||
}
|
||||
};
|
||||
|
||||
// Admin only middleware
|
||||
const admin = (req, res, next) => {
|
||||
if (req.user && req.user.role === 'admin') {
|
||||
next();
|
||||
} else {
|
||||
res.status(403);
|
||||
return next(new Error('Not authorized as an admin'));
|
||||
}
|
||||
};
|
||||
|
||||
// Staff or higher middleware
|
||||
const staff = (req, res, next) => {
|
||||
if (req.user && (req.user.role === 'admin' || req.user.role === 'supervisor' || req.user.role === 'staff')) {
|
||||
next();
|
||||
} else {
|
||||
res.status(403);
|
||||
return next(new Error('Not authorized as staff'));
|
||||
}
|
||||
};
|
||||
|
||||
// Supervisor or higher middleware
|
||||
const supervisor = (req, res, next) => {
|
||||
if (req.user && (req.user.role === 'admin' || req.user.role === 'supervisor')) {
|
||||
next();
|
||||
} else {
|
||||
res.status(403);
|
||||
return next(new Error('Not authorized as a supervisor'));
|
||||
}
|
||||
};
|
||||
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 15,
|
||||
message: "Too many login attempts. Try again later.",
|
||||
});
|
||||
|
||||
// Optional auth — populates req.user if a valid token is present, but never rejects the request
|
||||
const optionalAuth = async (req, res, next) => {
|
||||
if (!req.headers.authorization || !req.headers.authorization.startsWith('Bearer')) {
|
||||
return next();
|
||||
}
|
||||
try {
|
||||
const token = req.headers.authorization.split(' ')[1];
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.id },
|
||||
select: { id: true, name: true, email: true, role: true, isActive: true, createdAt: true, updatedAt: true, phoneNumber: true, tokenVersion: true }
|
||||
});
|
||||
if (user && user.isActive && (decoded.tokenVersion ?? 0) === user.tokenVersion) {
|
||||
req.user = user;
|
||||
}
|
||||
} catch {
|
||||
// Token invalid or expired — proceed without user
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = { protect, admin, staff, supervisor, loginLimiter, optionalAuth };
|
||||
@@ -0,0 +1,20 @@
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
|
||||
// Not found error handler — don't echo the URL back (leaks route structure)
|
||||
const notFound = (req, res, next) => {
|
||||
const error = new Error('Not Found');
|
||||
res.status(404);
|
||||
next(error);
|
||||
};
|
||||
|
||||
// General error handler
|
||||
const errorHandler = (err, req, res, next) => {
|
||||
const statusCode = res.statusCode === 200 ? 500 : res.statusCode;
|
||||
|
||||
res.status(statusCode).json({
|
||||
message: safeErrorMessage(err),
|
||||
stack: process.env.NODE_ENV === 'production' ? undefined : err.stack,
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { notFound, errorHandler };
|
||||
@@ -0,0 +1,9 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { scheduleAutomations } = require('../controllers/automationController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
|
||||
// Schedule multiple automation emails for an event
|
||||
router.post('/schedule', protect, supervisor, scheduleAutomations);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,9 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getBanner, setBanner } = require('../controllers/bannerController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/', getBanner);
|
||||
router.post('/', protect, supervisor, setBanner);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,11 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { previewBroadcast, sendBroadcast, scheduleBroadcast } = require('../controllers/broadcastController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
|
||||
// All routes require supervisor (or admin via middleware logic) access
|
||||
router.post('/preview', protect, supervisor, previewBroadcast);
|
||||
router.post('/send', protect, supervisor, sendBroadcast);
|
||||
router.post('/schedule', protect, supervisor, scheduleBroadcast);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,12 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventCashup, saveEventCashupDraft, closeEvent, reopenEvent, getCashupAudit } = require('../controllers/cashupController');
|
||||
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/audit', protect, supervisor, getCashupAudit);
|
||||
router.get('/event/:eventId', protect, supervisor, getEventCashup);
|
||||
router.put('/event/:eventId/draft', protect, admin, saveEventCashupDraft);
|
||||
router.post('/event/:eventId/close', protect, admin, closeEvent);
|
||||
router.post('/event/:eventId/reopen', protect, admin, reopenEvent);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,12 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventCosts, createEventCost, updateEventCost, deleteEventCost } = require('../controllers/costController');
|
||||
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
// Mounted at /api — paths below match the documented /api/events/:eventId/costs and /api/costs/:id shape
|
||||
router.get('/events/:eventId/costs', protect, supervisor, getEventCosts);
|
||||
router.post('/events/:eventId/costs', protect, admin, createEventCost);
|
||||
router.put('/costs/:id', protect, admin, updateEventCost);
|
||||
router.delete('/costs/:id', protect, admin, deleteEventCost);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,86 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
createEvent,
|
||||
getEvents,
|
||||
getAllEvents,
|
||||
getEventsAll,
|
||||
getEventById,
|
||||
updateEvent,
|
||||
getEventNotifyRecipients,
|
||||
updateEventNotifyRecipients,
|
||||
deleteEvent,
|
||||
createEventOption,
|
||||
updateEventOption,
|
||||
deleteEventOption,
|
||||
createOptionVariant,
|
||||
updateOptionVariant,
|
||||
deleteOptionVariant,
|
||||
listEventAttachments,
|
||||
uploadEventAttachment,
|
||||
deleteEventAttachment,
|
||||
attachmentsStatus,
|
||||
attachmentsSync,
|
||||
emailEventAttendees,
|
||||
scheduleEmailEventAttendees,
|
||||
whatsappEventAttendees,
|
||||
scheduleWhatsappEventAttendees,
|
||||
getEventByAlias
|
||||
} = require('../controllers/eventController');
|
||||
const { protect, admin, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
|
||||
// Public routes
|
||||
router.get('/', getEvents);
|
||||
|
||||
// Staff/supervisor/admin: all events including hidden, optional past/inactive filters
|
||||
router.get('/all', protect, getEventsAll);
|
||||
|
||||
// Admin routes
|
||||
router.get('/admin/all', protect, admin, getAllEvents);
|
||||
router.get('/attachments/status', protect, admin, attachmentsStatus);
|
||||
router.post('/attachments/sync', protect, admin, attachmentsSync);
|
||||
|
||||
// Public route for single event (keep after specific admin routes).
|
||||
// optionalAuth populates req.user when a valid token is present so staff/supervisor/admin
|
||||
// can still load inactive events (e.g. for cashup or editing) without being 404'd.
|
||||
router.get('/:id', optionalAuth, getEventById);
|
||||
router.get('/by-alias/:redirectUrl', getEventByAlias);
|
||||
|
||||
// Create/Update/Delete event
|
||||
router.post('/', protect, supervisor, createEvent);
|
||||
router.route('/:id')
|
||||
.put(protect, supervisor, updateEvent)
|
||||
.delete(protect, admin, deleteEvent);
|
||||
|
||||
// Notification recipients: who gets registration/payment/daily-summary emails for this event
|
||||
router.route('/:id/notify-recipients')
|
||||
.get(protect, supervisor, getEventNotifyRecipients)
|
||||
.put(protect, supervisor, updateEventNotifyRecipients);
|
||||
|
||||
// Attachments routes
|
||||
router.get('/:id/attachments', listEventAttachments); // public read (used by event detail page)
|
||||
router.post('/:id/attachments', protect, supervisor, ...uploadEventAttachment);
|
||||
router.delete('/:eventId/attachments/:attachmentId', protect, supervisor, deleteEventAttachment);
|
||||
|
||||
// Bulk email attendees
|
||||
router.post('/:id/email-attendees', protect, supervisor, emailEventAttendees);
|
||||
// Schedule bulk email to attendees
|
||||
router.post('/:id/email-attendees/schedule', protect, supervisor, scheduleEmailEventAttendees);
|
||||
// Bulk WhatsApp message to attendees
|
||||
router.post('/:id/whatsapp-attendees', protect, supervisor, whatsappEventAttendees);
|
||||
// Schedule bulk WhatsApp message to attendees
|
||||
router.post('/:id/whatsapp-attendees/schedule', protect, supervisor, scheduleWhatsappEventAttendees);
|
||||
|
||||
// Event options routes
|
||||
router.post('/:id/options', protect, supervisor, createEventOption);
|
||||
router.route('/options/:id')
|
||||
.put(protect, supervisor, updateEventOption)
|
||||
.delete(protect, admin, deleteEventOption);
|
||||
|
||||
// Option variant routes
|
||||
router.post('/options/:id/variants', protect, supervisor, createOptionVariant);
|
||||
router.route('/variants/:id')
|
||||
.put(protect, supervisor, updateOptionVariant)
|
||||
.delete(protect, admin, deleteOptionVariant);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,9 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listFormResponses } = require('../controllers/formController');
|
||||
const { protect, staff } = require('../middleware/authMiddleware');
|
||||
|
||||
// Staff or higher can view submitted forms
|
||||
router.get('/responses', protect, staff, listFormResponses);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,33 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
createPayment,
|
||||
getPayments,
|
||||
getUserPayments,
|
||||
getPaymentById,
|
||||
getPaymentsByRegistration,
|
||||
getPaymentsByEvent,
|
||||
assignDonationToRegistration,
|
||||
createYocoCheckout,
|
||||
sendPaymentLink,
|
||||
createRefund,
|
||||
getPaymentStats
|
||||
} = require('../controllers/paymentController');
|
||||
const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware');
|
||||
|
||||
// Protected routes
|
||||
router.post('/', protect, supervisor, createPayment);
|
||||
router.post('/yoco-checkout', protect, createYocoCheckout);
|
||||
router.post('/yoco-checkout/send', protect, supervisor, sendPaymentLink);
|
||||
router.get('/mypayments', protect, getUserPayments);
|
||||
router.get('/:id', protect, getPaymentById);
|
||||
router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
|
||||
|
||||
// Admin/Staff routes
|
||||
router.get('/', protect, supervisor, getPayments);
|
||||
router.get('/event/:eventId', protect, staff, getPaymentsByEvent);
|
||||
router.put('/assign-donation', protect, supervisor, assignDonationToRegistration);
|
||||
router.post('/refund', protect, supervisor, createRefund);
|
||||
router.get('/admin/stats', protect, admin, getPaymentStats);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,37 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
createRegistration,
|
||||
getRegistrations,
|
||||
getUserRegistrations,
|
||||
getRegistrationById,
|
||||
updateRegistrationStatus,
|
||||
cancelRegistration,
|
||||
getRegistrationsByEvent,
|
||||
createManualRegistration,
|
||||
updateRegistrationOptions,
|
||||
submitFormResponses, replaceFormResponses,
|
||||
getFormDraft, saveFormDraft,
|
||||
} = require('../controllers/registrationController');
|
||||
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
|
||||
|
||||
// Registration - auth optional when event doesn't require it
|
||||
router.post('/', optionalAuth, createRegistration);
|
||||
router.get('/myregistrations', protect, getUserRegistrations);
|
||||
router.put('/:id/options', protect, updateRegistrationOptions);
|
||||
router.delete('/:id', protect, cancelRegistration);
|
||||
|
||||
// Registration detail + forms — optionalAuth so guests can access with just the registrationId
|
||||
router.get('/:id', optionalAuth, getRegistrationById);
|
||||
router.get('/:id/forms/draft', optionalAuth, getFormDraft);
|
||||
router.put('/:id/forms/draft', optionalAuth, saveFormDraft);
|
||||
router.post('/:id/forms/responses', optionalAuth, submitFormResponses);
|
||||
router.put('/:id/forms/responses', protect, supervisor, replaceFormResponses);
|
||||
|
||||
// Admin/Staff routes
|
||||
router.get('/', protect, staff, getRegistrations);
|
||||
router.put('/:id', protect, staff, updateRegistrationStatus);
|
||||
router.get('/event/:eventId', protect, staff, getRegistrationsByEvent);
|
||||
router.post('/manual', protect, supervisor, createManualRegistration);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,12 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { generatePdf, emailPdf } = require('../controllers/reportController');
|
||||
const { protect } = require('../middleware/authMiddleware');
|
||||
|
||||
// Generate and download PDF
|
||||
router.post('/pdf', protect, generatePdf);
|
||||
|
||||
// Email PDF to current user
|
||||
router.post('/email', protect, emailPdf);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,11 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listScheduledEmails, updateScheduledEmail, deleteScheduledEmail } = require('../controllers/scheduledEmailsController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
|
||||
// All routes require supervisor (or admin via middleware logic) access
|
||||
router.get('/', protect, supervisor, listScheduledEmails);
|
||||
router.patch('/:id', protect, supervisor, updateScheduledEmail);
|
||||
router.delete('/:id', protect, supervisor, deleteScheduledEmail);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,16 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
getSections,
|
||||
createSection,
|
||||
deleteSection,
|
||||
updateSection
|
||||
} = require('../controllers/sectionController');
|
||||
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/', protect, staff, getSections);
|
||||
router.post('/', protect, supervisor, createSection);
|
||||
router.delete('/:id', protect, admin, deleteSection);
|
||||
router.put('/:id', protect, supervisor, updateSection);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,12 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getSettings, getAllSettings, updateSettings, needsSetup, testSmtp } = require('../controllers/settingsController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/needs-setup', needsSetup);
|
||||
router.get('/all', protect, admin, getAllSettings);
|
||||
router.get('/', getSettings);
|
||||
router.put('/', protect, admin, updateSettings);
|
||||
router.post('/test-smtp', protect, admin, testSmtp);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,12 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { setupRegister, runSetup } = require('../controllers/settingsController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
// Step 1 of setup: create the admin account, returns a JWT token
|
||||
router.post('/register', setupRegister);
|
||||
|
||||
// Step 2 of setup: finalize settings (requires the token from /register)
|
||||
router.post('/', protect, admin, runSetup);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,13 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getStaffDashboardStats, getSupervisorDashboardStats, getAdminDashboardStats } = require('../controllers/statsController');
|
||||
const { protect, staff, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
// One endpoint per dashboard — each returns exactly what that dashboard renders in a
|
||||
// single response instead of the dashboard making several separate calls (and, previously,
|
||||
// pulling full payments/events lists just to reduce them to a couple of numbers).
|
||||
router.get('/staff', protect, staff, getStaffDashboardStats);
|
||||
router.get('/supervisor', protect, supervisor, getSupervisorDashboardStats);
|
||||
router.get('/admin', protect, admin, getAdminDashboardStats);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,39 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
generateTickets,
|
||||
getTickets,
|
||||
getUserTickets,
|
||||
getTicketById,
|
||||
getTicketByQrCode,
|
||||
getScanPreview,
|
||||
scanTicket,
|
||||
getTicketsByEvent,
|
||||
markTicketsAsEmailSent,
|
||||
emailTickets,
|
||||
sendTicketsTo,
|
||||
getRecentScans,
|
||||
getScanStats
|
||||
} = require('../controllers/ticketController');
|
||||
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
|
||||
|
||||
// Protected routes
|
||||
router.get('/mytickets', protect, getUserTickets);
|
||||
router.get('/:id', protect, getTicketById);
|
||||
router.post('/email', protect, emailTickets);
|
||||
|
||||
// Staff routes
|
||||
router.post('/send-to', protect, staff, sendTicketsTo);
|
||||
router.get('/scan-preview/:qrCode', protect, staff, getScanPreview);
|
||||
router.get('/qr/:qrCode', protect, staff, getTicketByQrCode);
|
||||
router.post('/scan/:qrCode', protect, staff, scanTicket);
|
||||
router.get('/event/:eventId', protect, staff, getTicketsByEvent);
|
||||
router.get('/scans/recent', protect, staff, getRecentScans);
|
||||
router.get('/scans/stats', protect, staff, getScanStats);
|
||||
|
||||
// Admin routes
|
||||
router.post('/generate', protect, supervisor, generateTickets);
|
||||
router.get('/', protect, supervisor, getTickets);
|
||||
router.put('/email-sent', protect, admin, markTicketsAsEmailSent);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,41 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
const { upload, uploadEventImage, uploadLogo, uploadLogoImage } = require('../controllers/uploadController');
|
||||
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
const prisma = require('../config/db');
|
||||
|
||||
// @route POST /api/uploads/event-image
|
||||
// @desc Upload an image for an event
|
||||
// @access Private (Supervisor+ recommended)
|
||||
router.post('/event-image', protect, supervisor, (req, res, next) => {
|
||||
upload.single('image')(req, res, (err) => {
|
||||
if (err) {
|
||||
req.multerError = err;
|
||||
}
|
||||
next();
|
||||
});
|
||||
}, uploadEventImage);
|
||||
|
||||
// @route POST /api/uploads/logo
|
||||
// @desc Upload site logo — admin, OR allowed during first-time setup (no users yet)
|
||||
// @access Admin or setup
|
||||
|
||||
async function logoAccess(req, res, next) {
|
||||
try {
|
||||
const count = await prisma.user.count();
|
||||
if (count === 0) return next(); // first-time setup
|
||||
return protect(req, res, () => admin(req, res, next));
|
||||
} catch {
|
||||
return protect(req, res, () => admin(req, res, next));
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/logo', logoAccess, (req, res, next) => {
|
||||
uploadLogo.single('image')(req, res, (err) => {
|
||||
if (err) req.multerError = err;
|
||||
next();
|
||||
});
|
||||
}, uploadLogoImage);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,52 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
registerUser,
|
||||
loginUser,
|
||||
getUserProfile,
|
||||
updateUserProfile,
|
||||
getUsers,
|
||||
checkUserExists,
|
||||
getUserById,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
anonymizeUser,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
activateAccount,
|
||||
revokeMySession,
|
||||
adminRevokeUserSessions,
|
||||
closeAccount,
|
||||
} = require('../controllers/userController');
|
||||
const { protect, admin, supervisor, loginLimiter} = require('../middleware/authMiddleware');
|
||||
|
||||
// Public routes
|
||||
router.post('/', registerUser);
|
||||
router.post('/login', loginLimiter, loginUser);
|
||||
router.post('/forgot', requestPasswordReset);
|
||||
router.post('/reset', resetPassword);
|
||||
router.post('/activate', activateAccount);
|
||||
|
||||
// Protected routes
|
||||
router.route('/profile')
|
||||
.get(protect, getUserProfile)
|
||||
.put(protect, updateUserProfile);
|
||||
|
||||
router.post('/revoke-sessions', protect, revokeMySession);
|
||||
router.post('/close-account', protect, closeAccount);
|
||||
|
||||
// Admin routes
|
||||
router.route('/')
|
||||
.get(protect, supervisor, getUsers);
|
||||
|
||||
router.get('/check-exists', protect, supervisor, checkUserExists);
|
||||
|
||||
router.route('/:id')
|
||||
.get(protect, admin, getUserById)
|
||||
.put(protect, admin, updateUser)
|
||||
.delete(protect, admin, deleteUser);
|
||||
|
||||
router.post('/:id/revoke-sessions', protect, admin, adminRevokeUserSessions);
|
||||
router.post('/:id/anonymize', protect, admin, anonymizeUser);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,9 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { handleYocoWebhook, handleWhatsappWebhook } = require('../controllers/webhookController');
|
||||
|
||||
// Yoco webhook endpoint
|
||||
router.post('/yoco', handleYocoWebhook);
|
||||
router.post('/whatsapp', handleWhatsappWebhook);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,10 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { previewWhatsAppBroadcast, sendWhatsAppBroadcast, scheduleWhatsAppBroadcast } = require('../controllers/whatsappBroadcastController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
|
||||
router.post('/preview', protect, supervisor, previewWhatsAppBroadcast);
|
||||
router.post('/send', protect, supervisor, sendWhatsAppBroadcast);
|
||||
router.post('/schedule', protect, supervisor, scheduleWhatsAppBroadcast);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,37 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
getConfigHandler,
|
||||
saveConfigHandler,
|
||||
createInstanceHandler,
|
||||
deleteInstanceHandler,
|
||||
getStatusHandler,
|
||||
getQrHandler,
|
||||
requestCodeHandler,
|
||||
logoutHandler,
|
||||
startHandler,
|
||||
restartHandler,
|
||||
handleWebhook,
|
||||
} = require('../controllers/whatsappController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
// Public webhook (called by WAWP servers)
|
||||
router.post('/webhook', handleWebhook);
|
||||
|
||||
// Admin-only: credentials config
|
||||
router.get( '/config', protect, admin, getConfigHandler);
|
||||
router.post('/config', protect, admin, saveConfigHandler);
|
||||
|
||||
// Admin-only: instance lifecycle
|
||||
router.post('/create-instance', protect, admin, createInstanceHandler);
|
||||
router.post('/delete-instance', protect, admin, deleteInstanceHandler);
|
||||
|
||||
// Admin-only: session management
|
||||
router.get( '/status', protect, admin, getStatusHandler);
|
||||
router.get( '/qr', protect, admin, getQrHandler);
|
||||
router.post('/request-code', protect, admin, requestCodeHandler);
|
||||
router.post('/logout', protect, admin, logoutHandler);
|
||||
router.post('/start', protect, admin, startHandler);
|
||||
router.post('/restart', protect, admin, restartHandler);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,18 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getAllYocoTransactions, getUnreconciledYocoTransactions, reconcileYocoTransaction, ignoreYocoTransaction } = require('../controllers/yocoTransactionsController');
|
||||
const { protect, supervisor} = require('../middleware/authMiddleware');
|
||||
|
||||
// GET all Yoco transactions (admin/supervisor)
|
||||
router.get('/', protect, supervisor, getAllYocoTransactions);
|
||||
|
||||
// GET all unreconciled Yoco transactions (admin/supervisor)
|
||||
router.get('/unreconciled', protect, supervisor, getUnreconciledYocoTransactions);
|
||||
|
||||
// POST reconcile a Yoco transaction
|
||||
router.post('/:id/reconcile', protect, supervisor, reconcileYocoTransaction);
|
||||
|
||||
// POST ignore a Yoco transaction
|
||||
router.post('/:id/ignore', protect, supervisor, ignoreYocoTransaction);
|
||||
|
||||
module.exports = router;
|
||||
@@ -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