Files
hope-events/backend/src/controllers/eventController.js
T

1078 lines
44 KiB
JavaScript

const prisma = require('../config/db');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { getClientIp } = require('../utils/requestUtils');
const sessionsUtil = require('../utils/sessions');
const { publicOptions } = require('../utils/volunteers');
const { campusesEnabled, campusWhere, resolveCampusFilter, EVENT_CAMPUS_SELECT } = require('../utils/campuses');
const { scopeFor } = require('../utils/campusScope');
// Setting events up is shared with /api/v2/events (TypeScript, 1.51.7).
const eventService = require('../services/events');
const { SERIES_SELECT, SESSIONS_INCLUDE, withSchedule } = eventService;
const details = require('../services/eventDetails');
// @desc Copy allocation groups (and, for admins, planned costs) from another event into a new copy
// @route POST /api/events/:id/copy-from
// @access Private/Supervisor
const copyEventSetupFrom = async (req, res) => {
try {
res.json(await eventService.copySetupFrom(req.params.id, req.body?.fromEventId, req.user));
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// Helper to convert stored picture path/URL to an absolute, externally reachable URL based on the incoming request
function toAbsoluteUrl(req, url) {
if (!url) return url;
try {
const raw = String(url).trim();
if (!raw) return null;
// If already absolute
if (raw.startsWith('http://') || raw.startsWith('https://')) {
const u = new URL(raw);
// Rewrite localhost to the actual request host
if (['localhost', '127.0.0.1', '::1'].includes(u.hostname)) {
const origin = `${req.protocol}://${req.get('host')}`;
const base = new URL(origin);
u.protocol = base.protocol;
u.hostname = base.hostname;
u.port = base.port;
return u.toString();
}
return raw; // keep as-is if already absolute and not localhost
}
// Relative path like /uploads/... => prefix with request origin
if (raw.startsWith('/')) {
return `${req.protocol}://${req.get('host')}${raw}`;
}
// Any other case, return as-is
return raw;
} catch {
return url;
}
}
// @desc Create a new event
// @route POST /api/events
// @access Private/Admin
const createEvent = async (req, res) => {
try {
res.status(201).json(await eventService.createEvent(req.body || {}, req.user, { scope: await scopeFor(req), ip: getClientIp(req) }));
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// Map of eventId → when its early bird ends, for events with at least one early-bird
// tier still running: deadline not passed and (no stock limit, or places left, counted
// across tranches the same way as utils/pricing.js). The date is the latest deadline
// among the running tiers.
async function earlyBirdEndsByEvent(events) {
const now = new Date();
const running = [];
for (const ev of events) {
for (const opt of ev.eventOptions || []) {
for (const tier of opt.earlyBirdTiers || []) {
if (new Date(tier.deadline) > now) running.push({ eventId: ev.id, tier });
}
}
}
const limitedIds = running.filter(r => r.tier.stockLimit > 0).map(r => r.tier.id);
const sold = new Map();
if (limitedIds.length > 0) {
const totals = await prisma.registrationOptionTranche.groupBy({
by: ['appliedTierId'],
where: {
appliedTierId: { in: limitedIds },
registrationOption: { registration: { status: { not: 'cancelled' } } }
},
_sum: { quantity: true }
});
for (const t of totals) sold.set(t.appliedTierId, t._sum?.quantity || 0);
}
const ends = new Map();
for (const { eventId, tier } of running) {
if (tier.stockLimit > 0 && (sold.get(tier.id) || 0) >= tier.stockLimit) continue;
const deadline = new Date(tier.deadline);
const current = ends.get(eventId);
if (!current || deadline > current) ends.set(eventId, deadline);
}
return ends;
}
// @desc Get all events
// @route GET /api/events
// @access Public
const getEvents = async (req, res) => {
try {
let events;
// Determine if Prisma Client supports EarlyBirdTier relation (i.e., migration+generate applied)
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
const eventOptionsInclude = canIncludeTiers ? { include: { earlyBirdTiers: true } } : true;
// ?campus=<slug or id>: that campus's events plus church-wide ones.
const enabled = await campusesEnabled();
const campusFilter = campusWhere(enabled ? await resolveCampusFilter(req.query.campus) : '', { enabled, withAllCampuses: true });
// ?series=<slug>: one series' events.
if (req.query.series) campusFilter.series = { slug: String(req.query.series) };
try {
events = await prisma.event.findMany({
where: {
isActive: true,
isHidden: false,
goLiveAt: { lte: new Date() },
endDate: { gte: new Date() },
...campusFilter,
},
include: { eventOptions: eventOptionsInclude, sessions: SESSIONS_INCLUDE, campus: { select: EVENT_CAMPUS_SELECT }, series: SERIES_SELECT }
});
} catch (e) {
// Fallback if goLiveAt/isHidden not available yet (pre-migration)
events = await prisma.event.findMany({
where: {
isActive: true,
endDate: { gte: new Date() }
},
include: { eventOptions: eventOptionsInclude }
});
}
const earlyBirdEnds = await earlyBirdEndsByEvent(events).catch(() => new Map());
// Compute isSoldOut per event: true when every option that has a stock limit
// is fully sold out. Events with no limited options are never sold out.
const withStock = await Promise.all(events.map(async listed => {
const ev = { ...listed, eventOptions: publicOptions(listed) };
const earlyBirdEndsAt = earlyBirdEnds.get(ev.id) || null;
try {
// Picked-session options have per-session limits, so they never make the event sold out.
const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0 && !sessionsUtil.isPickOption(ev, o));
let isSoldOut = false;
if (limitedOpts.length > 0) {
const { takenCount } = require('../utils/stock');
const soldCounts = await Promise.all(limitedOpts.map(opt => takenCount({ eventOptionId: opt.id })));
isSoldOut = limitedOpts.every((opt, i) => soldCounts[i] >= opt.stockLimit);
}
return withSchedule({ ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut, earlyBirdEndsAt });
} catch {
return withSchedule({ ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut: false, earlyBirdEndsAt });
}
}));
res.json(withStock);
} catch (error) {
res.status(400).json({ message: error.message });
}
};
// @desc Get all events (including inactive)
// @route GET /api/events/all
// @access Private/Admin
const getAllEvents = async (req, res) => {
try {
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
const events = await prisma.event.findMany({
include: {
eventOptions: canIncludeTiers ? { include: { earlyBirdTiers: true } } : true,
sessions: SESSIONS_INCLUDE,
campus: { select: EVENT_CAMPUS_SELECT },
}
});
const mapped = events.map(ev => withSchedule({
...ev,
picture: toAbsoluteUrl(req, ev.picture),
}));
res.json(mapped);
} catch (error) {
// If prisma client doesn't support the relation include (rare), retry without it
const events = await prisma.event.findMany({ include: { eventOptions: true } });
const mapped = events.map(ev => ({ ...ev, picture: toAbsoluteUrl(req, ev.picture) }));
res.json(mapped);
}
};
// @desc Get all events for staff/supervisor/admin (always includes hidden; optional past/inactive)
// @route GET /api/events/all
// @access Private
const getEventsAll = async (req, res) => {
try {
const events = await eventService.listManagedEvents({
includePast: req.query.includePast === 'true',
includeInactive: req.query.includeInactive === 'true',
excludeClosed: req.query.excludeClosed === 'true',
campus: req.query.campus,
}, { scope: await scopeFor(req) });
res.json(events.map(ev => ({ ...ev, picture: toAbsoluteUrl(req, ev.picture) })));
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// @desc Get event by ID
// @route GET /api/events/:id
// @access Public
const getEventById = async (req, res) => {
try {
res.json(await details.getEventDetail(req.params.id, { staff: !!req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role), scope: await scopeFor(req) }, (u) => toAbsoluteUrl(req, u)));
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// @desc Download a .ics calendar file for an event
// @route GET /api/events/:id/ics
// @access Public (same visibility gating as getEventById)
const getEventIcs = async (req, res) => {
try {
const { filename, ics } = await details.eventCalendar(req.params.id, { staff: !!req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role), scope: await scopeFor(req) });
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(ics);
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// @desc Update event
// @route PUT /api/events/:id
// @access Private/Admin
const updateEvent = async (req, res) => {
try {
res.json(await eventService.updateEvent(req.params.id, req.body || {}, req.user, { scope: await scopeFor(req), ip: getClientIp(req) }));
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// @desc Get the list of users who receive registration/payment/daily-summary
// notifications for this event. Lightweight — selects only id/name/email/role,
// skipping the option/stock computation that GET /api/events/:id does, so the
// Notifications step in the event editor loads instantly.
// @route GET /api/events/:id/notify-recipients
// @access Private/Supervisor
const getEventNotifyRecipients = async (req, res) => {
try {
res.json(await eventService.getNotifyRecipients(req.params.id));
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// @desc Set the list of users who receive registration/payment/daily-summary
// notifications for this event. Empty list falls back to the event creator.
// @route PUT /api/events/:id/notify-recipients
// @access Private/Supervisor
const updateEventNotifyRecipients = async (req, res) => {
try {
res.json(await eventService.setNotifyRecipients(req.params.id, req.body?.userIds));
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// @desc Delete event (set inactive)
// @route DELETE /api/events/:id
// @access Private/Admin
const deleteEvent = async (req, res) => {
try {
await eventService.deactivateEvent(req.params.id, req.user, { scope: await scopeFor(req), ip: getClientIp(req) });
res.json({ message: 'Event deactivated' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// Optional staff price from a request body: '' / null clears it, undefined leaves it.
// @desc Create event option
// @route POST /api/events/:id/options
// @access Private/Admin
const createEventOption = async (req, res) => {
try {
res.status(201).json(await details.createOption(req.params.id, req.body || {}));
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// @desc Update event option
// @route PUT /api/events/options/:id
// @access Private/Admin
const updateEventOption = async (req, res) => {
try {
res.json(await details.updateOption(req.params.id, req.body || {}));
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// @desc Delete event option
// @route DELETE /api/events/options/:id
// @access Private/Admin
const deleteEventOption = async (req, res) => {
try {
await details.deleteOption(req.params.id);
res.json({ message: 'Event option removed' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// @desc Create a variant for an event option
// @route POST /api/events/options/:id/variants
// @access Private/Supervisor
const createOptionVariant = async (req, res) => {
try {
res.status(201).json(await details.createVariant(req.params.id, req.body || {}));
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// @desc Update a variant
// @route PUT /api/events/variants/:id
// @access Private/Supervisor
const updateOptionVariant = async (req, res) => {
try {
res.json(await details.updateVariant(req.params.id, req.body || {}));
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// @desc Delete a variant
// @route DELETE /api/events/variants/:id
// @access Private/Admin
const deleteOptionVariant = async (req, res) => {
try {
await details.deleteVariant(req.params.id);
res.json({ message: 'Variant deleted' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// File upload for attachments (PDFs/docs), shared with /api/v2/events/{id}/attachments.
const attachmentsStorage = multer.diskStorage({
destination: function (req, file, cb) {
try {
if (!fs.existsSync(details.ATTACHMENTS_DIR)) fs.mkdirSync(details.ATTACHMENTS_DIR, { recursive: true });
cb(null, details.ATTACHMENTS_DIR);
} catch (err) {
cb(err);
}
},
filename: function (req, file, cb) {
// Extension only — file.originalname is untrusted and joining it into a
// path allows `../` traversal to write outside the upload directory.
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `event-file-${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`);
}
});
const uploadAttachmentMulter = multer({
storage: attachmentsStorage,
limits: { fileSize: 20 * 1024 * 1024 }, // 20MB
fileFilter: (req, file, cb) => {
if (!details.ALLOWED_DOCS.includes(path.extname(file.originalname).toLowerCase())) return cb(new Error('Unsupported file type'));
cb(null, true);
}
});
// Saves the upload (field "file"); an error is kept on req for the handler to report.
const receiveAttachment = (req, res, next) => uploadAttachmentMulter.single('file')(req, res, (e) => {
if (e) req.multerError = e;
next();
});
// @desc List attachments for an event
// @route GET /api/events/:id/attachments
// @access Private/Supervisor (for dashboard) and could be public via event details
const listEventAttachments = async (req, res) => {
try {
res.json(await details.listAttachments(req.params.id, (u) => toAbsoluteUrl(req, u)));
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// @desc Upload attachment for an event
// @route POST /api/events/:id/attachments
// @access Private/Supervisor
const uploadEventAttachment = [
receiveAttachment,
async (req, res) => {
try {
if (req.multerError) throw req.multerError;
if (!req.file) return res.status(400).json({ message: 'No file uploaded' });
res.status(201).json(await details.recordAttachment(req.params.id, req.file, (u) => toAbsoluteUrl(req, u)));
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
}
];
// @desc Delete attachment
// @route DELETE /api/events/:eventId/attachments/:attachmentId
// @access Private/Admin or Supervisor
const deleteEventAttachment = async (req, res) => {
try {
await details.deleteAttachment(req.params.eventId, req.params.attachmentId);
res.json({ message: 'Attachment deleted' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// @desc Diagnostics for attachments pipeline
// @route GET /api/events/attachments/status
// @access Private/Admin
const attachmentsStatus = async (req, res) => {
try {
res.json(await details.attachmentsStatus());
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// @desc Admin-triggered sync: import manifest files into DB
// @route POST /api/events/attachments/sync
// @access Private/Admin
const attachmentsSync = async (req, res) => {
try {
res.json(await details.syncAttachmentManifests({ dryRun: String(req.query.dryRun || '').toLowerCase() === 'true', remove: String(req.query.remove || '').toLowerCase() === 'true' }));
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// @desc Email attendees for an event with filters and preview support
// @route POST /api/events/:id/email-attendees
// @access Private/Supervisor or Admin
const emailEventAttendees = async (req, res) => {
try {
const eventId = req.params.id;
const { subject, html, text, filter, dryRun, template } = req.body || {};
// For non-ticket templates or custom messages, require subject and content
if ((template !== 'tickets') && (!subject || !(html || text))) {
res.status(400);
throw new Error('Subject and message (html or text) are required unless template is "tickets"');
}
// Ensure event exists
const event = await prisma.event.findUnique({ where: { id: eventId } });
if (!event) { res.status(404); throw new Error('Event not found'); }
// Optional promo event (used by Automations: Next Event Promo)
let promoEvent = null;
try {
const promoEventId = req.body?.promoEventId;
if (promoEventId && typeof promoEventId === 'string') {
promoEvent = await prisma.event.findUnique({ where: { id: promoEventId } });
}
} catch {}
// Build where clause for registrations
const where = { eventId };
// Filter by registration status groups: paid, unpaid, partial_paid, cancelled, any
const statusFilter = (filter && filter.status) ? String(filter.status) : 'any';
if (statusFilter === 'paid') {
where.status = 'paid';
} else if (statusFilter === 'unpaid') {
where.status = { in: ['pending', 'partial_paid'] };
} else if (statusFilter === 'partial_paid') {
where.status = 'partial_paid';
} else if (statusFilter === 'cancelled') {
where.status = 'cancelled';
}
// Only registrations with someone in this allocation group ("everyone on Bus 2").
if (filter?.allocationGroupId) {
where.attendees = { some: { allocations: { some: { groupId: String(filter.allocationGroupId) } } } };
}
// Optional explicit attendee selection by userIds
const attendeeIds = Array.isArray(filter?.attendeeIds) ? filter.attendeeIds.filter((x) => typeof x === 'string' && x.trim().length > 0) : [];
if (attendeeIds.length > 0) {
// Prefer server-side filtering when possible
where.userId = { in: attendeeIds };
}
// Bulk actions (1.49.1): exactly the registrations ticked on the registrations list.
const registrationIds = Array.isArray(filter?.registrationIds) ? filter.registrationIds.filter((x) => typeof x === 'string' && x.trim()).slice(0, 2000) : [];
if (registrationIds.length > 0) where.id = { in: registrationIds };
// Name/email substring filter (kept for backward compatibility)
const nameQ = (filter && filter.name) ? String(filter.name).trim() : '';
const emailQ = (filter && filter.email) ? String(filter.email).trim() : '';
const registrations = await prisma.registration.findMany({
where,
include: {
user: { select: { id: true, name: true, email: true, isActive: true } },
// Include options with eventOption and early-bird tiers so pricing/balances compute correctly
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
payments: true,
},
orderBy: { createdAt: 'asc' }
});
// Apply name/email filter in JS (Prisma cross-relational OR contains with guards varies across versions)
// Also exclude guest/system-generated emails and suspended users
let filtered = registrations.filter(r => {
if (!r.user || !r.user.email) return false;
if (r.user.email.endsWith('@guest.local')) return false;
if (r.user.isActive === false) return false;
return true;
});
if (nameQ) {
const q = nameQ.toLowerCase();
filtered = filtered.filter(r => (r.user?.name || '').toLowerCase().includes(q));
}
if (emailQ) {
const q = emailQ.toLowerCase();
filtered = filtered.filter(r => (r.user?.email || '').toLowerCase().includes(q));
}
// Unique recipients by email to avoid duplicates across multiple registrations
const uniqMap = new Map();
for (const r of filtered) {
const email = (r.user?.email || '').trim();
if (!email) continue;
if (!uniqMap.has(email)) {
uniqMap.set(email, { email, name: r.user?.name || '' });
}
}
const recipients = Array.from(uniqMap.values());
// Sponsors of this event instead of its attendees (e.g. the post-event thank-you).
if (filter?.audience === 'sponsors') {
recipients.length = 0;
registrations.length = 0;
const sponsorships = await prisma.payment.findMany({
where: { eventId, isDonation: true, isSponsorship: true, amount: { gt: 0 } },
include: { user: { select: { name: true, email: true, isActive: true } } },
orderBy: { createdAt: 'asc' },
});
const seen = new Set();
for (const s of sponsorships) {
const email = (s.user?.email || '').trim();
if (!email || email.endsWith('@guest.local') || email.endsWith('@deleted.invalid') || seen.has(email)) continue;
seen.add(email);
recipients.push({ email, name: s.sponsorName || s.user?.name || '' });
}
}
// POPIA: an "update" (about this event) goes to everyone selected; "news" (the next-event
// promo, or when the sender says so) only to people who opted in. Tickets are always sent.
const { normaliseKind, filterRecipients, bulkEmailFooter } = require('../utils/privacy');
const kind = template === 'tickets' ? 'update' : (promoEvent ? 'news' : (normaliseKind(req.body?.kind) || 'update'));
const allowed = await filterRecipients(recipients, { channel: 'email', kind });
recipients.length = 0;
recipients.push(...allowed.recipients);
if (dryRun) {
return res.json({ eventId, kind, matched: recipients.length, skipped: allowed.skipped, recipients: recipients.slice(0, 20) });
}
const { sendMail } = require('../utils/email');
const { computeRegistrationTotalDue } = require('../utils/pricing');
const { replacePlaceholders } = require('../utils/placeholders');
function fmtAmount(amt) {
const n = Number(amt || 0);
return `R${n.toFixed(2)}`;
}
function fmtDate(d) {
try { return new Date(d).toLocaleString(); } catch { return String(d); }
}
// Build per-recipient registration aggregates for this event
const regsByEmail = new Map();
for (const r of registrations) {
const em = (r.user?.email || '').trim();
if (!em) continue;
if (!regsByEmail.has(em)) regsByEmail.set(em, []);
regsByEmail.get(em).push(r);
}
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
// Generates a live checkout link for the recipient's first registration with an
// outstanding balance — only called when the template actually uses {{payment.link}}, to
// avoid an unnecessary Yoco API call per recipient otherwise.
function makePaymentLinkResolver(regs) {
return async () => {
const reg = (regs || []).find(r => {
const due = computeRegistrationTotalDue(r, new Date());
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
return due - paid > 0.01;
});
if (!reg) return '';
const { createRegistrationCheckout } = require('../payments/checkouts');
const result = await createRegistrationCheckout(reg.id, reg.userId, {
successUrl: `${baseUrl}/payment/success`,
cancelUrl: `${baseUrl}/payment/cancel`,
failureUrl: `${baseUrl}/payment/failure`,
});
return result.redirectUrl;
};
}
// Fire-and-forget: respond immediately with a queued count, then send in the background.
// Large recipient lists used to block the request until every email was sent — now the
// caller gets an instant response and failures are just logged server-side.
if (template === 'tickets') {
const { emailTickets } = require('./ticketController');
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: 'tickets' });
(async () => {
const results = await Promise.allSettled(recipients.map(async rcpt => {
const regs = regsByEmail.get(rcpt.email) || [];
for (const reg of regs) {
const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } };
const mockRes = { status: () => mockRes, json: () => {} };
await emailTickets(mockReq, mockRes);
}
}));
results.forEach((r, i) => {
if (r.status === 'rejected') {
try { console.warn('[email-attendees tickets] Failed for', recipients[i]?.email, r.reason?.message || r.reason); } catch {}
}
});
})();
return;
}
const eventTitle = event?.title || 'the event';
const eventStart = event?.startDate ? fmtDate(event.startDate) : '';
// Build event and promo links for placeholders
const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`;
const eventLinkHtml = `<a href="${eventLink}">${eventLink}</a>`;
const promoTitle = promoEvent?.title || '';
const promoLink = promoEvent ? `${baseUrl}/events/${encodeURIComponent(promoEvent.id)}` : '';
const promoLinkHtml = promoLink ? `<a href="${promoLink}">${promoLink}</a>` : '';
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: template || 'custom' });
(async () => {
const results = await Promise.allSettled(recipients.map(async rcpt => {
const regs = regsByEmail.get(rcpt.email) || [];
// Sum outstanding balance across this user's registrations for the event
let totalDue = 0; let totalPaid = 0;
for (const r of regs) {
const due = computeRegistrationTotalDue(r, new Date());
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
totalDue += due; totalPaid += paid;
}
const balance = Math.max(totalDue - totalPaid, 0);
const paymentLinkResolver = makePaymentLinkResolver(regs);
// Build context for placeholder replacement
const ctxBase = {
name: rcpt.name || '',
eventTitle,
eventStart,
eventLink,
promoTitle,
promoLink,
balance,
balanceFmt: fmtAmount(balance),
paymentLinkResolver,
};
const ctxHtml = {
...ctxBase,
eventLinkHtml,
promoLinkHtml,
};
// If a known template is selected and no explicit content provided, auto-build content
let subj = subject;
let h = html; let t = text;
if (!h && !t && template) {
if (template === 'payment_reminder') {
subj = `Payment reminder: ${eventTitle}`;
t = `Hi {{name}}\n\nThis is a friendly reminder that you have an outstanding balance of {{balance}} for {{event.title}}.\nEvent starts: {{event.start}}\n\nPlease settle your balance to secure your tickets. Thank you!`;
h = `
<div style="font-family:Arial,sans-serif;line-height:1.6;color:#333">
<h2>Payment reminder</h2>
<p>Hi {{name}},</p>
<p>You have an outstanding balance of <strong>{{balance}}</strong> for <strong>{{event.title}}</strong>.</p>
${eventStart ? `<p><strong>Event starts:</strong> {{event.start}}</p>` : ''}
<p>Please settle your balance to secure your tickets. Thank you!</p>
</div>`;
} else if (template === 'event_reminder') {
subj = `Reminder: ${eventTitle} on ${eventStart || ''}`.trim();
t = `Hi {{name}}\n\nA quick reminder about {{event.title}}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`;
h = `
<div style="font-family:Arial,sans-serif;line-height:1.6;color:#333">
<h2>Event reminder</h2>
<p>Hi {{name}},</p>
<p>This is a reminder for <strong>{{event.title}}</strong>.</p>
${eventStart ? `<p><strong>Start:</strong> {{event.start}}</p>` : ''}
<p>We look forward to seeing you!</p>
</div>`;
}
}
// Always perform placeholder replacement on whatever we have
const finalSubject = await replacePlaceholders(subj || '', ctxBase);
const footer = bulkEmailFooter({ kind, recipient: rcpt, eventTitle: event.title });
const finalHtml = h ? (await replacePlaceholders(h, ctxHtml)) + footer.html : undefined;
const finalText = (!h ? (await replacePlaceholders(t || '', ctxBase)) + footer.text : undefined);
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText, headers: footer.headers });
}));
results.forEach((r, i) => {
if (r.status === 'rejected') {
try { console.warn('[email-attendees] Failed for', recipients[i]?.email, r.reason?.message || r.reason); } catch {}
}
});
})();
} catch (error) {
return res.status(400).json({ message: error.message });
}
};
// @desc Send WhatsApp message to event attendees with filters and preview support
// @route POST /api/events/:id/whatsapp-attendees
// @access Private/Supervisor or Admin
const whatsappEventAttendees = async (req, res) => {
try {
const eventId = req.params.id;
const { message, filter, dryRun, template } = req.body || {};
if (template !== 'tickets' && !message) {
res.status(400);
throw new Error('message is required unless template is "tickets"');
}
const event = await prisma.event.findUnique({ where: { id: eventId } });
if (!event) { res.status(404); throw new Error('Event not found'); }
const where = { eventId };
const statusFilter = (filter && filter.status) ? String(filter.status) : 'any';
if (statusFilter === 'paid') {
where.status = 'paid';
} else if (statusFilter === 'unpaid') {
where.status = { in: ['pending', 'partial_paid'] };
} else if (statusFilter === 'partial_paid') {
where.status = 'partial_paid';
} else if (statusFilter === 'cancelled') {
where.status = 'cancelled';
}
// Only registrations with someone in this allocation group ("everyone on Bus 2").
if (filter?.allocationGroupId) {
where.attendees = { some: { allocations: { some: { groupId: String(filter.allocationGroupId) } } } };
}
const attendeeIds = Array.isArray(filter?.attendeeIds) ? filter.attendeeIds.filter((x) => typeof x === 'string' && x.trim().length > 0) : [];
if (attendeeIds.length > 0) {
where.userId = { in: attendeeIds };
}
// Bulk actions (1.49.1): exactly the registrations ticked on the registrations list.
const registrationIds = Array.isArray(filter?.registrationIds) ? filter.registrationIds.filter((x) => typeof x === 'string' && x.trim()).slice(0, 2000) : [];
if (registrationIds.length > 0) where.id = { in: registrationIds };
const nameQ = (filter && filter.name) ? String(filter.name).trim() : '';
const phoneQ = (filter && filter.phone) ? String(filter.phone).trim() : '';
const registrations = await prisma.registration.findMany({
where,
include: {
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
payments: true,
},
orderBy: { createdAt: 'asc' }
});
const { isValidZAPhone, normalizeZAPhone } = require('../utils/whatsapp');
let filtered = registrations.filter(r => {
if (!r.user) return false;
if (r.user.email && r.user.email.endsWith('@guest.local')) return false;
if (r.user.isActive === false) return false;
if (!isValidZAPhone(r.user.phoneNumber)) return false;
return true;
});
if (nameQ) {
const q = nameQ.toLowerCase();
filtered = filtered.filter(r => (r.user?.name || '').toLowerCase().includes(q));
}
if (phoneQ) {
filtered = filtered.filter(r => (r.user?.phoneNumber || '').includes(phoneQ));
}
// Unique recipients by phone number
const uniqMap = new Map();
for (const r of filtered) {
const phone = normalizeZAPhone(r.user?.phoneNumber);
if (!phone) continue;
if (!uniqMap.has(phone)) {
uniqMap.set(phone, { phone, name: r.user?.name || '', userId: r.user?.id, registrationId: r.id });
}
}
// POPIA: see emailEventAttendees — news only to people who opted in to WhatsApp news.
const { normaliseKind, filterRecipients, whatsAppNewsFooter } = require('../utils/privacy');
const promoEventId = req.body?.promoEventId;
const kind = template === 'tickets' ? 'update' : (promoEventId ? 'news' : (normaliseKind(req.body?.kind) || 'update'));
const allowed = await filterRecipients(Array.from(uniqMap.values()), { channel: 'whatsapp', kind });
const recipients = allowed.recipients;
if (dryRun) {
return res.json({ eventId, kind, matched: recipients.length, skipped: allowed.skipped, recipients: recipients.slice(0, 20) });
}
const { sendText } = require('../utils/whatsapp');
const { computeRegistrationTotalDue } = require('../utils/pricing');
const { replacePlaceholders } = require('../utils/placeholders');
function fmtAmount(amt) {
const n = Number(amt || 0);
return `R${n.toFixed(2)}`;
}
function fmtDate(d) {
try { return new Date(d).toLocaleString(); } catch { return String(d); }
}
const regsByPhone = new Map();
for (const r of registrations) {
const phone = normalizeZAPhone(r.user?.phoneNumber);
if (!phone) continue;
if (!regsByPhone.has(phone)) regsByPhone.set(phone, []);
regsByPhone.get(phone).push(r);
}
const eventTitle = event?.title || 'the event';
const eventStart = event?.startDate ? fmtDate(event.startDate) : '';
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`;
// Generates a live checkout link for the recipient's first registration with an
// outstanding balance — only called when the template actually uses {{payment.link}}.
function makePaymentLinkResolver(regs) {
return async () => {
const reg = (regs || []).find(r => {
const due = computeRegistrationTotalDue(r, new Date());
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
return due - paid > 0.01;
});
if (!reg) return '';
const { createRegistrationCheckout } = require('../payments/checkouts');
const result = await createRegistrationCheckout(reg.id, reg.userId, {
successUrl: `${baseUrl}/payment/success`,
cancelUrl: `${baseUrl}/payment/cancel`,
failureUrl: `${baseUrl}/payment/failure`,
});
return result.redirectUrl;
};
}
// Fire-and-forget: respond immediately with a queued count, then send in the background.
if (template === 'tickets') {
const { emailTickets } = require('./ticketController');
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: 'tickets' });
(async () => {
const results = await Promise.allSettled(recipients.map(async rcpt => {
const regs = regsByPhone.get(rcpt.phone) || [];
for (const reg of regs) {
const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } };
const mockRes = { status: () => mockRes, json: () => {} };
await emailTickets(mockReq, mockRes);
}
}));
results.forEach((r, i) => {
if (r.status === 'rejected') {
try { console.warn('[whatsapp-attendees tickets] Failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {}
}
});
})();
return;
}
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: template || 'custom' });
(async () => {
const results = await Promise.allSettled(recipients.map(async rcpt => {
const regs = regsByPhone.get(rcpt.phone) || [];
let totalDue = 0; let totalPaid = 0;
for (const r of regs) {
const due = computeRegistrationTotalDue(r, new Date());
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
totalDue += due; totalPaid += paid;
}
const balance = Math.max(totalDue - totalPaid, 0);
const ctx = {
name: rcpt.name || '',
eventTitle,
eventStart,
eventLink,
balance,
balanceFmt: fmtAmount(balance),
paymentLinkResolver: makePaymentLinkResolver(regs),
};
let msg = message;
if (!msg && template === 'payment_reminder') {
msg = `Hi {{name}}\n\nThis is a friendly reminder that you have an outstanding balance of {{balance}} for {{event.title}}.\nEvent starts: {{event.start}}\n\nPlease settle your balance to secure your tickets. Thank you!`;
} else if (!msg && template === 'event_reminder') {
msg = `Hi {{name}}\n\nA quick reminder about {{event.title}}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`;
}
const finalMessage = await replacePlaceholders(msg || '', ctx);
await sendText(rcpt.phone, kind === 'news' ? finalMessage + whatsAppNewsFooter(rcpt) : finalMessage);
}));
results.forEach((r, i) => {
if (r.status === 'rejected') {
try { console.warn('[whatsapp-attendees] Failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {}
}
});
})();
} catch (error) {
return res.status(400).json({ message: error.message });
}
};
// Human-readable summary of who an attendees-scoped send will go to, for the scheduled-jobs admin UI
function describeAttendeeFilter(filter) {
if (Array.isArray(filter?.attendeeIds) && filter.attendeeIds.length > 0) {
return `${filter.attendeeIds.length} selected attendee${filter.attendeeIds.length === 1 ? '' : 's'}`;
}
const labels = { paid: 'Paid attendees', unpaid: 'Unpaid attendees', partial_paid: 'Partially paid attendees', cancelled: 'Cancelled registrations' };
return labels[filter?.status] || 'All attendees';
}
// @desc Schedule email to attendees at a specific date/time
// @route POST /api/events/:id/email-attendees/schedule
// @access Private/Supervisor or Admin
const scheduleEmailEventAttendees = async (req, res) => {
try {
const eventId = req.params.id;
const { scheduledAt, subject, html, text, filter, template, kind } = req.body || {};
// Validate event
const event = await prisma.event.findUnique({ where: { id: eventId } });
if (!event) { res.status(404); throw new Error('Event not found'); }
// Validate date
if (!scheduledAt) { res.status(400); throw new Error('scheduledAt is required'); }
const when = new Date(scheduledAt);
if (isNaN(when.getTime())) { res.status(400); throw new Error('scheduledAt must be a valid ISO date-time'); }
// For non-ticket templates or custom messages, require subject and content
if ((template !== 'tickets') && (!subject || !(html || text))) {
res.status(400);
throw new Error('Subject and message (html or text) are required unless template is "tickets"');
}
// Build payload matching emailEventAttendees body
const payload = { subject, html, text, filter: filter || {}, template, kind };
const { addJob } = require('../utils/scheduledEmails');
const created = addJob({
eventId,
createdById: req.user?.id || null,
scheduledAt: when.toISOString(),
recipientSummary: `${event.title} — ${describeAttendeeFilter(filter)}`,
payload,
});
return res.status(201).json({ message: 'Email scheduled', job: created });
} catch (error) {
return res.status(400).json({ message: error.message });
}
};
// @desc Schedule WhatsApp message to attendees at a specific date/time
// @route POST /api/events/:id/whatsapp-attendees/schedule
// @access Private/Supervisor or Admin
const scheduleWhatsappEventAttendees = async (req, res) => {
try {
const eventId = req.params.id;
const { scheduledAt, message, filter, template, kind } = req.body || {};
const event = await prisma.event.findUnique({ where: { id: eventId } });
if (!event) { res.status(404); throw new Error('Event not found'); }
if (!scheduledAt) { res.status(400); throw new Error('scheduledAt is required'); }
const when = new Date(scheduledAt);
if (isNaN(when.getTime())) { res.status(400); throw new Error('scheduledAt must be a valid ISO date-time'); }
if (template !== 'tickets' && !message) {
res.status(400);
throw new Error('message is required unless template is "tickets"');
}
const payload = { message, filter: filter || {}, template, kind };
const { addJob } = require('../utils/scheduledEmails');
const created = addJob({
eventId,
channel: 'whatsapp',
createdById: req.user?.id || null,
scheduledAt: when.toISOString(),
recipientSummary: `${event.title} — ${describeAttendeeFilter(filter)}`,
payload,
});
return res.status(201).json({ message: 'WhatsApp message scheduled', job: created });
} catch (error) {
return res.status(400).json({ message: error.message });
}
};
/**
* @desc Get event by redirectUrl (alias)
* @route GET /api/events/by-alias/:redirectUrl
* @access Public
*/
// Aliases are admin-set slugs (e.g. "camp-2025") — see the event wizard's "URL
// Alias" field. This endpoint is public and also the target of the frontend's
// catch-all [redirectUrl] route, so it's what every bot/scanner probe hitting
// an unmatched top-level path (/wp-login.php, /.env, etc.) ends up calling.
// Rejecting non-slug-shaped values here skips a DB round-trip for that traffic.
const getEventByAlias = async (req, res) => {
try {
res.json(await details.eventByAlias(req.params.redirectUrl));
} catch (error) {
res.status(error.statusCode || 500).json({ message: error.statusCode ? error.message : 'Internal server error' });
}
};
module.exports = {
receiveAttachment,
createEvent,
copyEventSetupFrom,
getEvents,
getAllEvents,
getEventsAll,
getEventById,
getEventIcs,
updateEvent,
getEventNotifyRecipients,
updateEventNotifyRecipients,
deleteEvent,
createEventOption,
updateEventOption,
deleteEventOption,
createOptionVariant,
updateOptionVariant,
deleteOptionVariant,
listEventAttachments,
uploadEventAttachment,
deleteEventAttachment,
attachmentsStatus,
attachmentsSync,
emailEventAttendees,
scheduleEmailEventAttendees,
whatsappEventAttendees,
scheduleWhatsappEventAttendees,
getEventByAlias
};