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