Initial commit

Next.js + Express event management app for Hope Family Church.
This commit is contained in:
2026-07-23 15:26:47 +02:00
commit 3d381944d2
246 changed files with 57565 additions and 0 deletions
@@ -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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
// 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 };
+186
View File
@@ -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
};
+177
View File
@@ -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
+52
View File
@@ -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
+275
View File
@@ -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 };
+141
View File
@@ -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,
};
+795
View File
@@ -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
};
+103
View File
@@ -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,
};
+934
View File
@@ -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
};