Files
hope-events/backend/src/controllers/eventController.js
T
joshuaandClaude Sonnet 5 0de3f4be7d Fix financial double-counting, rebuild cashup accountability, and redesign the Reports page
Financial correctness (donation-leg model):
- Donations are no longer mutated when assigned to a registration; assignment now
  creates an immutable "leg" record referencing the original donation instead.
- Fixed several places where money was double-counted once a donation was partially
  or fully assigned (Payments, Revenue summary, Cashup reconciliation, Finance
  report, Profit report, Master Orders, Revenue Detailed).
- Payments now record who recorded them (recordedBy), separate from who they're for.

Cashup:
- Per-user cash denomination counting (optional, any time) replaces the single
  event-wide manual entry; the event's cash actual is the live sum of these counts.
- New "Payment accountability by staff member" breakdown across all methods, and a
  read-only "Report" tab that opens automatically once an event is closed.

Reports page redesign:
- New shell: sidebar of universal filters (events, date range, past/inactive/closed
  toggles), searchable/categorized report grid, and a popup viewer with
  Print/Email/Excel/WhatsApp actions plus an in-app Reporting Guide.
- Visual pass: colored stat tiles and bar charts on most reports, matching mockups.
- PDF exports (download/Print/Email/WhatsApp) now share a branded design mirroring
  the web report — colored header, stat tiles, bar chart, highlighted totals.
- Excel export now produces a styled .xlsx (via exceljs) instead of a plain CSV.
- Master Orders' "Donations made" table is now included in every export channel.

Bug fixes discovered while testing exports:
- Report emails now go through the shared, DB-configurable mail utility instead of
  a one-off transporter that ignored Site Settings SMTP config.
- WhatsApp report sends now surface the actual WAWP API error and auto-recover a
  disconnected session, instead of a bare axios status-code message.

Also: Admin-editable notification preference, richer Admin Registrations dashboard,
{{payment.link}} placeholder for Email/WhatsApp Attendees, and background
email/WhatsApp attendee sending.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 14:52:54 +02:00

1725 lines
67 KiB
JavaScript

const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { assertEventOpen } = require('../utils/cashupUtils');
// 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 {
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth } = req.body;
const data = {
id: uuidv4(),
title,
description,
startDate: new Date(startDate),
endDate: new Date(endDate),
registrationDeadline: registrationDeadline ? new Date(registrationDeadline) : null,
goLiveAt: goLiveAt ? new Date(goLiveAt) : undefined,
price: parseFloat(price),
picture,
updatedAt: new Date(),
createdById: req.user?.id || undefined,
redirectUrl,
isHidden: isHidden === true || isHidden === 'true',
requiresAuth: requiresAuth === false || requiresAuth === 'false' ? false : true,
};
try {
const event = await prisma.event.create({ data });
// Automatically create a main ticket (event option) with the event price
try {
if (prisma && prisma.eventOption && typeof prisma.eventOption.create === 'function') {
await prisma.eventOption.create({
data: {
id: uuidv4(),
eventId: event.id,
name: 'Main Ticket',
price: event.price || 0,
isMainTicket: true,
}
});
}
} catch (e) {
// Non-fatal: event is created even if option creation fails
try { console.warn('[createEvent] Failed to auto-create main ticket option:', e?.message || e); } catch {}
}
// Create form if provided
try {
const formInput = req.body?.form;
if (formInput && prisma && prisma.eventForm) {
const createdForm = await prisma.eventForm.create({
data: {
id: uuidv4(),
eventId: event.id,
isRequired: !!formInput.isRequired,
}
});
const fields = Array.isArray(formInput.fields) ? formInput.fields : [];
for (let i = 0; i < fields.length; i++) {
const f = fields[i];
if (!f || !f.type || !f.label) continue;
await prisma.eventFormField.create({
data: {
id: uuidv4(),
formId: createdForm.id,
type: f.type,
label: String(f.label),
isRequired: !!f.isRequired,
order: typeof f.order === 'number' ? f.order : i,
helpText: f.helpText || null,
options: f.options || undefined,
}
});
}
}
} catch (e) {
const msg = String(e?.message || e || '');
try { console.error('[createEvent] Failed to save form/fields:', msg); } catch {}
return res.status(400).json({ message: 'Failed to save event form/fields', detail: msg, hint: 'Ensure Prisma migrations are applied and Prisma Client is regenerated, then restart the server.' });
}
return res.status(201).json(event);
} catch (err) {
// Gracefully handle environments where migration isn't applied yet
const msg = String(err?.message || '');
if (msg.includes('Unknown argument `registrationDeadline`')) {
// @ts-ignore delete field and retry
delete data.registrationDeadline;
const event = await prisma.event.create({ data });
return res.status(201).json(event);
}
if (msg.includes('Unknown argument `goLiveAt`')) {
// @ts-ignore delete field and retry
delete data.goLiveAt;
const event = await prisma.event.create({ data });
return res.status(201).json(event);
}
if (msg.includes('Unknown argument `createdById`')) {
// @ts-ignore delete field and retry
delete data.createdById;
const event = await prisma.event.create({ data });
return res.status(201).json(event);
}
if (msg.includes('Unknown argument `redirectUrl`')) {
// @ts-ignore delete field and retry
delete data.redirectUrl;
const event = await prisma.event.create({ data });
return res.status(201).json(event);
}
throw err;
}
} catch (error) {
res.status(400).json({ message: error.message });
}
};
// @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;
try {
events = await prisma.event.findMany({
where: {
isActive: true,
isHidden: false,
goLiveAt: { lte: new Date() },
endDate: { gte: new Date() }
},
include: { eventOptions: eventOptionsInclude }
});
} 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 }
});
}
// 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 ev => {
try {
const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0);
let isSoldOut = false;
if (limitedOpts.length > 0) {
const soldCounts = await Promise.all(limitedOpts.map(opt =>
prisma.registrationOption.aggregate({
where: { eventOptionId: opt.id, registration: { status: { not: 'cancelled' } } },
_sum: { quantity: true },
}).then(r => r._sum?.quantity || 0)
));
isSoldOut = limitedOpts.every((opt, i) => soldCounts[i] >= opt.stockLimit);
}
return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut };
} catch {
return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut: false };
}
}));
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
}
});
const mapped = events.map(ev => ({
...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 includePast = req.query.includePast === 'true';
const includeInactive = req.query.includeInactive === 'true';
const excludeClosed = req.query.excludeClosed === 'true';
const where = {};
if (!includeInactive) where.isActive = true;
if (!includePast) where.endDate = { gte: new Date() };
if (excludeClosed) where.cashupStatus = { not: 'closed' };
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
const optionInclude = canIncludeTiers
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) }
: undefined;
const events = await prisma.event.findMany({
where,
include: {
eventOptions: optionInclude ? { include: optionInclude } : true,
form: { include: { fields: true } }
},
orderBy: { startDate: 'asc' }
});
const mapped = events.map(ev => ({
...ev,
picture: toAbsoluteUrl(req, ev.picture),
}));
res.json(mapped);
} catch (error) {
res.status(400).json({ message: error.message });
}
};
// @desc Get event by ID
// @route GET /api/events/:id
// @access Public
const getEventById = async (req, res) => {
try {
const eventId = req.params.id;
// Determine if Prisma Client supports EarlyBirdTier / OptionVariant
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
const optionInclude = canIncludeTiers
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) }
: undefined;
const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true };
// Staff/supervisor/admin need the creator + notify-recipient list to populate the
// event edit screen's Notifications step; public callers don't need this.
const isStaffOrHigherForNotify = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role));
if (isStaffOrHigherForNotify) {
includeObj.createdBy = { select: { id: true, name: true, email: true } };
includeObj.notifyRecipients = { select: { id: true, name: true, email: true, role: true } };
}
let event;
// Try to include attachments via Prisma if the client has the model
const canUsePrismaAttachments = (prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findMany === 'function');
if (canUsePrismaAttachments) {
try {
// @ts-ignore - runtime guard ensures this exists
includeObj.attachments = true;
event = await prisma.event.findUnique({ where: { id: eventId }, include: includeObj });
} catch (e) {
// If relation/table does not exist (migration not deployed), fall back to fetching without attachments
try {
event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true, ...(isStaffOrHigherForNotify ? { createdBy: includeObj.createdBy, notifyRecipients: includeObj.notifyRecipients } : {}) } });
} catch (e2) {
// Notify-recipients relation not migrated yet either — fall back further
event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true } });
}
// And we will source attachments from the filesystem manifest below
}
} else {
// No model available on the Prisma client; fetch without attachments
try {
event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true, ...(isStaffOrHigherForNotify ? { createdBy: includeObj.createdBy, notifyRecipients: includeObj.notifyRecipients } : {}) } });
} catch (e) {
event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true } });
}
}
if (!event) {
res.status(404);
throw new Error('Event not found');
}
// Public gating: hide inactive or not-yet-live events from everyone except staff/supervisor/admin,
// who need to load inactive events for cashup, editing, etc.
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role));
if (!isStaffOrHigher) {
if (event.isActive === false) {
res.status(404);
throw new Error('Event not found');
}
// goLiveAt may not exist on older schemas — tolerate that read failing without
// affecting the isActive gate above.
let goLiveAt = null;
try { goLiveAt = event.goLiveAt ? new Date(event.goLiveAt) : null; } catch (e) {}
if (goLiveAt && new Date() < goLiveAt) {
res.status(404);
throw new Error('Event not found');
}
}
// Resolve attachments
let attachments = [];
if (event.attachments && Array.isArray(event.attachments)) {
attachments = event.attachments;
} else {
// Fall back to manifest on disk if DB attachments are not available
try {
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
if (fs.existsSync(manifestPath)) {
const raw = fs.readFileSync(manifestPath, 'utf-8');
const list = JSON.parse(raw) || [];
attachments = list;
}
} catch {}
}
// Try include form definition if available
let form = null;
try {
if (prisma && prisma.eventForm && typeof prisma.eventForm.findUnique === 'function') {
const f = await prisma.eventForm.findUnique({
where: { eventId: eventId },
include: { fields: true },
});
if (f) {
form = {
id: f.id,
isRequired: !!f.isRequired,
fields: (f.fields || []).sort((a,b) => (a.order||0)-(b.order||0)).map(fl => ({
id: fl.id,
type: fl.type,
label: fl.label,
isRequired: !!fl.isRequired,
order: fl.order || 0,
helpText: fl.helpText || null,
options: fl.options || null,
}))
};
}
}
} catch {}
// Compute availableCount per option and variant
let eventOptionsWithStock = event.eventOptions || [];
try {
if (canIncludeVariants) {
eventOptionsWithStock = await Promise.all((event.eventOptions || []).map(async opt => {
// Total sold quantity for this option (all non-cancelled)
const soldAgg = await prisma.registrationOption.aggregate({
where: { eventOptionId: opt.id, registration: { status: { not: 'cancelled' } } },
_sum: { quantity: true }
});
const soldCount = soldAgg._sum?.quantity || 0;
const availableCount = opt.stockLimit > 0 ? Math.max(0, opt.stockLimit - soldCount) : null;
// Per-variant stock
const variantsWithStock = await Promise.all((opt.variants || []).map(async v => {
if (!v.stockLimit) return { ...v, soldCount: 0, availableCount: null };
const vAgg = await prisma.registrationOption.aggregate({
where: { variantId: v.id, registration: { status: { not: 'cancelled' } } },
_sum: { quantity: true }
});
const vSold = vAgg._sum?.quantity || 0;
return { ...v, soldCount: vSold, availableCount: Math.max(0, v.stockLimit - vSold) };
}));
return { ...opt, soldCount, availableCount, variants: variantsWithStock };
}));
}
} catch (e) {
// Non-fatal: fall back to options without stock info
}
const mapped = {
...event,
eventOptions: eventOptionsWithStock,
picture: toAbsoluteUrl(req, event.picture),
attachments: (attachments || []).map(att => ({
...att,
url: toAbsoluteUrl(req, att.url)
})),
form,
};
res.json(mapped);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
}
};
// @desc Update event
// @route PUT /api/events/:id
// @access Private/Admin
const updateEvent = async (req, res) => {
try {
const event = await prisma.event.findUnique({
where: { id: req.params.id }
});
if (!event) {
res.status(404);
throw new Error('Event not found');
}
// A closed (cashed-up) event's pricing/structure must not change under the reconciled
// totals — same rule already enforced for payments/costs. Admin can reopen first.
await assertEventOpen(req.params.id, res);
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth } = req.body;
const data = {
title: title || event.title,
description: description !== undefined ? description : event.description,
startDate: startDate ? new Date(startDate) : event.startDate,
endDate: endDate ? new Date(endDate) : event.endDate,
registrationDeadline: registrationDeadline !== undefined ? (registrationDeadline ? new Date(registrationDeadline) : null) : event.registrationDeadline,
goLiveAt: goLiveAt !== undefined ? (goLiveAt ? new Date(goLiveAt) : new Date()) : (event.goLiveAt || undefined),
price: price ? parseFloat(price) : event.price,
picture: picture !== undefined ? picture : event.picture,
isActive: isActive !== undefined ? isActive : event.isActive,
isHidden: isHidden !== undefined ? (isHidden === true || isHidden === 'true') : (event.isHidden ?? false),
requiresAuth: requiresAuth !== undefined ? !(requiresAuth === false || requiresAuth === 'false') : (event.requiresAuth ?? true),
updatedAt: new Date(),
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
};
try {
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
// Upsert form if provided
try {
const formInput = req.body?.form;
if (formInput && prisma && prisma.eventForm) {
// find existing form
let existing = null;
try { existing = await prisma.eventForm.findUnique({ where: { eventId: updatedEvent.id } }); } catch {}
if (!existing) {
existing = await prisma.eventForm.create({ data: { id: uuidv4(), eventId: updatedEvent.id, isRequired: !!formInput.isRequired } });
} else {
await prisma.eventForm.update({ where: { id: existing.id }, data: { isRequired: !!formInput.isRequired } });
}
const formId = existing.id;
// Replace fields: simple approach — delete all and recreate
try { await prisma.eventFormField.deleteMany({ where: { formId } }); } catch {}
const fields = Array.isArray(formInput.fields) ? formInput.fields : [];
for (let i = 0; i < fields.length; i++) {
const f = fields[i];
if (!f || !f.type || !f.label) continue;
await prisma.eventFormField.create({
data: {
id: uuidv4(),
formId,
type: f.type,
label: String(f.label),
isRequired: !!f.isRequired,
order: typeof f.order === 'number' ? f.order : i,
helpText: f.helpText || null,
options: f.options || undefined,
}
});
}
}
} catch (e) {
const msg = String(e?.message || e || '');
try { console.error('[updateEvent] Failed to save form/fields:', msg); } catch {}
return res.status(400).json({ message: 'Failed to save event form/fields', detail: msg, hint: 'Ensure Prisma migrations are applied and Prisma Client is regenerated, then restart the server.' });
}
return res.json(updatedEvent);
} catch (err) {
const msg = String(err?.message || '');
if (msg.includes('Unknown argument `registrationDeadline`')) {
// @ts-ignore
delete data.registrationDeadline;
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
return res.json(updatedEvent);
}
if (msg.includes('Unknown argument `goLiveAt`')) {
// @ts-ignore
delete data.goLiveAt;
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
return res.json(updatedEvent);
}
throw err;
}
} catch (error) {
res.status(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 {
const event = await prisma.event.findUnique({
where: { id: req.params.id },
select: { notifyRecipients: { select: { id: true, name: true, email: true, role: true } } },
});
if (!event) {
res.status(404);
throw new Error('Event not found');
}
res.json(event.notifyRecipients);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).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 {
const { userIds } = req.body;
if (!Array.isArray(userIds)) {
res.status(400);
throw new Error('userIds must be an array');
}
const event = await prisma.event.findUnique({ where: { id: req.params.id } });
if (!event) {
res.status(404);
throw new Error('Event not found');
}
const updated = await prisma.event.update({
where: { id: req.params.id },
data: { notifyRecipients: { set: userIds.map(id => ({ id })) } },
include: { notifyRecipients: { select: { id: true, name: true, email: true, role: true } } },
});
res.json(updated.notifyRecipients);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
}
};
// @desc Delete event (set inactive)
// @route DELETE /api/events/:id
// @access Private/Admin
const deleteEvent = async (req, res) => {
try {
const event = await prisma.event.findUnique({
where: { id: req.params.id }
});
if (!event) {
res.status(404);
throw new Error('Event not found');
}
// Instead of deleting, we set isActive to false
await prisma.event.update({
where: { id: req.params.id },
data: {
isActive: false,
updatedAt: new Date()
}
});
res.json({ message: 'Event deactivated' });
} catch (error) {
res.status(400).json({ message: error.message });
}
};
// @desc Create event option
// @route POST /api/events/:id/options
// @access Private/Admin
const createEventOption = async (req, res) => {
try {
const event = await prisma.event.findUnique({
where: { id: req.params.id }
});
if (!event) {
res.status(404);
throw new Error('Event not found');
}
await assertEventOpen(req.params.id, res);
const { name, price, isMainTicket, stockLimit } = req.body;
const eventOption = await prisma.eventOption.create({
data: {
id: uuidv4(),
eventId: req.params.id,
name,
price: parseFloat(price),
isMainTicket: isMainTicket || false,
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
}
});
res.status(201).json(eventOption);
} catch (error) {
res.status(400).json({ message: error.message });
}
};
// @desc Update event option
// @route PUT /api/events/options/:id
// @access Private/Admin
const updateEventOption = async (req, res) => {
try {
const eventOption = await prisma.eventOption.findUnique({
where: { id: req.params.id },
include: { earlyBirdTiers: true }
});
if (!eventOption) {
res.status(404);
throw new Error('Event option not found');
}
await assertEventOpen(eventOption.eventId, res);
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants } = req.body;
const updatedEventOption = await prisma.eventOption.update({
where: { id: req.params.id },
data: {
name: name !== undefined ? name : eventOption.name,
price: price !== undefined ? parseFloat(price) : eventOption.price,
isMainTicket: isMainTicket !== undefined ? isMainTicket : eventOption.isMainTicket,
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : eventOption.stockLimit,
}
});
// If earlyBirdTiers provided, replace all tiers for this option (including per-variant ones)
if (Array.isArray(earlyBirdTiers)) {
try { await prisma.earlyBirdTier.deleteMany({ where: { eventOptionId: updatedEventOption.id } }); } catch {}
for (let i = 0; i < earlyBirdTiers.length; i++) {
const t = earlyBirdTiers[i];
if (!t || !t.deadline || (t.price === undefined || t.price === null)) continue;
const deadline = new Date(t.deadline);
const p = parseFloat(t.price);
if (!(deadline instanceof Date) || isNaN(deadline.getTime()) || !(p >= 0)) continue;
await prisma.earlyBirdTier.create({
data: {
id: require('uuid').v4(),
eventOptionId: updatedEventOption.id,
variantId: t.variantId || null,
deadline,
price: p,
order: typeof t.order === 'number' ? t.order : i,
stockLimit: t.stockLimit ? parseInt(t.stockLimit, 10) : 0,
}
});
}
}
// If variants array provided, upsert variants
if (Array.isArray(variants)) {
const incomingIds = variants.filter(v => v.id).map(v => v.id);
// Delete variants not in the new list (only if they have no registrations)
const existingVariants = await prisma.optionVariant.findMany({ where: { eventOptionId: updatedEventOption.id } });
for (const ev of existingVariants) {
if (!incomingIds.includes(ev.id)) {
const usageCount = await prisma.registrationOption.count({ where: { variantId: ev.id } });
if (usageCount === 0) {
await prisma.optionVariant.delete({ where: { id: ev.id } });
}
}
}
// Upsert incoming variants
for (let i = 0; i < variants.length; i++) {
const v = variants[i];
if (!v.name) continue;
const variantPrice = v.price !== undefined && v.price !== null && v.price !== '' ? parseFloat(v.price) : null;
const variantStockLimit = v.stockLimit !== undefined ? parseInt(v.stockLimit, 10) : 0;
if (v.id) {
await prisma.optionVariant.update({
where: { id: v.id },
data: { name: v.name, price: variantPrice, stockLimit: variantStockLimit, order: i }
});
} else {
await prisma.optionVariant.create({
data: {
id: require('uuid').v4(),
eventOptionId: updatedEventOption.id,
name: v.name,
price: variantPrice,
stockLimit: variantStockLimit,
order: i,
}
});
}
}
}
const refreshed = await prisma.eventOption.findUnique({
where: { id: updatedEventOption.id },
include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } }
});
res.json(refreshed);
} catch (error) {
res.status(400).json({ message: error.message });
}
};
// @desc Delete event option
// @route DELETE /api/events/options/:id
// @access Private/Admin
const deleteEventOption = async (req, res) => {
try {
const eventOption = await prisma.eventOption.findUnique({
where: { id: req.params.id }
});
if (!eventOption) {
res.status(404);
throw new Error('Event option not found');
}
await assertEventOpen(eventOption.eventId, res);
// If deleting a main ticket, ensure at least one other main ticket exists for the same event
if (eventOption.isMainTicket) {
const otherMainCount = await prisma.eventOption.count({
where: {
eventId: eventOption.eventId,
isMainTicket: true,
NOT: { id: eventOption.id },
}
});
if (otherMainCount === 0) {
res.status(400);
throw new Error('Cannot delete the only main ticket option');
}
}
// Check if there are any registrations using this option
const registrationOptions = await prisma.registrationOption.findMany({
where: { eventOptionId: req.params.id }
});
if (registrationOptions.length > 0) {
res.status(400);
throw new Error('Cannot delete event option that has registrations');
}
await prisma.eventOption.delete({
where: { id: req.params.id }
});
res.json({ message: 'Event option removed' });
} catch (error) {
res.status(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 {
const eventOption = await prisma.eventOption.findUnique({ where: { id: req.params.id } });
if (!eventOption) { res.status(404); throw new Error('Event option not found'); }
await assertEventOpen(eventOption.eventId, res);
const { name, price, stockLimit, order } = req.body;
if (!name) { res.status(400); throw new Error('Variant name is required'); }
const variant = await prisma.optionVariant.create({
data: {
id: uuidv4(),
eventOptionId: req.params.id,
name,
price: price !== undefined && price !== null && price !== '' ? parseFloat(price) : null,
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
order: order !== undefined ? parseInt(order, 10) : 0,
}
});
res.status(201).json(variant);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
}
};
// @desc Update a variant
// @route PUT /api/events/variants/:id
// @access Private/Supervisor
const updateOptionVariant = async (req, res) => {
try {
const variant = await prisma.optionVariant.findUnique({ where: { id: req.params.id }, include: { eventOption: { select: { eventId: true } } } });
if (!variant) { res.status(404); throw new Error('Variant not found'); }
await assertEventOpen(variant.eventOption.eventId, res);
const { name, price, stockLimit, order } = req.body;
const updated = await prisma.optionVariant.update({
where: { id: req.params.id },
data: {
name: name !== undefined ? name : variant.name,
price: price !== undefined ? (price === null || price === '' ? null : parseFloat(price)) : variant.price,
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : variant.stockLimit,
order: order !== undefined ? parseInt(order, 10) : variant.order,
}
});
res.json(updated);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
}
};
// @desc Delete a variant
// @route DELETE /api/events/variants/:id
// @access Private/Admin
const deleteOptionVariant = async (req, res) => {
try {
const variant = await prisma.optionVariant.findUnique({ where: { id: req.params.id }, include: { eventOption: { select: { eventId: true } } } });
if (!variant) { res.status(404); throw new Error('Variant not found'); }
await assertEventOpen(variant.eventOption.eventId, res);
const usageCount = await prisma.registrationOption.count({ where: { variantId: req.params.id } });
if (usageCount > 0) { res.status(400); throw new Error('Cannot delete variant with existing registrations'); }
await prisma.optionVariant.delete({ where: { id: req.params.id } });
res.json({ message: 'Variant deleted' });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
}
};
// File upload setup for attachments (PDFs/docs)
const attachmentsStorage = multer.diskStorage({
destination: function (req, file, cb) {
const uploadPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
try {
if (!fs.existsSync(uploadPath)) fs.mkdirSync(uploadPath, { recursive: true });
cb(null, uploadPath);
} catch (err) {
cb(err);
}
},
filename: function (req, file, cb) {
const unique = `${Date.now()}-${file.originalname}`;
cb(null, unique);
}
});
const allowedDocs = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.csv', '.zip'];
const uploadAttachmentMulter = multer({
storage: attachmentsStorage,
limits: { fileSize: 20 * 1024 * 1024 }, // 20MB
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (!allowedDocs.includes(ext)) {
return cb(new Error('Unsupported file type'));
}
cb(null, true);
}
});
// @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 {
const eventId = req.params.id;
// If attachments model isn't available yet (pre-migration/generate), fall back to filesystem manifest
if (!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findMany === 'function')) {
try {
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
if (!fs.existsSync(manifestPath)) return res.json([]);
const raw = fs.readFileSync(manifestPath, 'utf-8');
const list = JSON.parse(raw);
return res.json(list.map(att => ({ ...att, url: toAbsoluteUrl(req, att.url) })));
} catch (e) {
// If manifest is corrupt or unreadable, return empty list rather than failing
return res.json([]);
}
}
const items = await prisma.eventAttachment.findMany({ where: { eventId }, orderBy: { createdAt: 'desc' } });
res.json(items.map(att => ({ ...att, url: toAbsoluteUrl(req, att.url) })));
} catch (error) {
res.status(400).json({ message: error.message });
}
};
// @desc Upload attachment for an event
// @route POST /api/events/:id/attachments
// @access Private/Supervisor
const uploadEventAttachment = [
(req, res, next) => uploadAttachmentMulter.single('file')(req, res, (err) => {
if (err) req.multerError = err;
next();
}),
async (req, res) => {
try {
if (req.multerError) throw req.multerError;
if (!req.file) return res.status(400).json({ message: 'No file uploaded' });
const eventId = req.params.id;
const relUrl = `/uploads/event-files/${req.file.filename}`;
// Fallback: if attachments model is not available yet, store in filesystem manifest
if (!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.create === 'function')) {
try { console.warn('[attachments] Falling back to filesystem manifest: Prisma client lacks EventAttachment model. Ensure prisma generate ran in the deployed app dir and server restarted.'); } catch {}
try {
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
const entry = {
id: uuidv4(),
eventId,
originalName: req.file.originalname,
filename: req.file.filename,
mimeType: req.file.mimetype,
size: req.file.size,
url: relUrl,
createdAt: new Date().toISOString()
};
let list = [];
if (fs.existsSync(manifestPath)) {
try {
list = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) || [];
} catch {}
}
list.unshift(entry);
fs.writeFileSync(manifestPath, JSON.stringify(list, null, 2), 'utf-8');
return res.status(201).json({ ...entry, url: toAbsoluteUrl(req, entry.url) });
} catch (e) {
return res.status(500).json({ message: 'Failed to record attachment', detail: e?.message });
}
}
// Normal path: DB-backed
try {
const created = await prisma.eventAttachment.create({
data: {
id: uuidv4(),
eventId,
originalName: req.file.originalname,
filename: req.file.filename,
mimeType: req.file.mimetype,
size: req.file.size,
url: relUrl,
}
});
return res.status(201).json({ ...created, url: toAbsoluteUrl(req, created.url) });
} catch (e) {
const msg = String(e?.message || e || '');
const isMissingTable = msg.includes('does not exist') || msg.includes('relation') || msg.includes('P2021');
if (isMissingTable) {
try { console.warn('[attachments] DB create failed, falling back to filesystem manifest. Error:', msg); } catch {}
try {
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
const entry = {
id: uuidv4(),
eventId,
originalName: req.file.originalname,
filename: req.file.filename,
mimeType: req.file.mimetype,
size: req.file.size,
url: relUrl,
createdAt: new Date().toISOString()
};
let list = [];
if (fs.existsSync(manifestPath)) {
try { list = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) || []; } catch {}
}
list.unshift(entry);
fs.writeFileSync(manifestPath, JSON.stringify(list, null, 2), 'utf-8');
return res.status(201).json({ ...entry, url: toAbsoluteUrl(req, entry.url) });
} catch (fallbackErr) {
return res.status(500).json({ message: 'Failed to record attachment', detail: String(fallbackErr?.message || fallbackErr) });
}
}
// Unknown error
throw e;
}
} catch (error) {
res.status(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 {
const { eventId, attachmentId } = req.params;
// If attachments model is not available yet, fall back to filesystem manifest
if (!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findUnique === 'function')) {
try {
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
if (!fs.existsSync(manifestPath)) return res.status(404).json({ message: 'Attachment not found' });
const list = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) || [];
const idx = list.findIndex((x) => x.id === attachmentId);
if (idx === -1) return res.status(404).json({ message: 'Attachment not found' });
const att = list[idx];
// Remove file on disk (best-effort)
try {
const filePath = path.join(uploadDir, att.filename);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch {}
// Remove from manifest
list.splice(idx, 1);
fs.writeFileSync(manifestPath, JSON.stringify(list, null, 2), 'utf-8');
return res.json({ message: 'Attachment deleted' });
} catch (e) {
return res.status(500).json({ message: 'Failed to delete attachment', detail: e?.message });
}
}
const att = await prisma.eventAttachment.findUnique({ where: { id: attachmentId } });
if (!att || att.eventId !== eventId) {
return res.status(404).json({ message: 'Attachment not found' });
}
// Remove file on disk (best-effort)
try {
const filePath = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files', att.filename);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch {}
await prisma.eventAttachment.delete({ where: { id: attachmentId } });
res.json({ message: 'Attachment deleted' });
} catch (error) {
res.status(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 {
const hasModel = !!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findMany === 'function');
let tableQueryable = false;
let probeError = null;
if (hasModel) {
try {
await prisma.eventAttachment.findFirst({});
tableQueryable = true;
} catch (e) {
probeError = String(e?.message || e);
}
}
// Mask DB URL to avoid leaking secrets
const dbUrl = process.env.DATABASE_URL || '';
let dbInfo = null;
try {
const u = new URL(dbUrl);
dbInfo = {
protocol: u.protocol.replace(':',''),
host: u.hostname,
port: u.port || undefined,
database: (u.pathname || '').replace(/^\//,'') || undefined,
};
} catch {}
res.json({
prismaClientHasEventAttachmentModel: hasModel,
databaseTableQueryable: tableQueryable,
probeError,
database: dbInfo,
});
} catch (error) {
res.status(400).json({ message: error.message });
}
};
// @desc Admin-triggered sync: import manifest files into DB
// @route POST /api/events/attachments/sync
// @access Private/Admin
const { syncManifestsToDb } = require('../utils/attachmentsSync');
const attachmentsSync = async (req, res) => {
try {
const dryRun = String(req.query.dryRun || '').toLowerCase() === 'true';
const remove = String(req.query.remove || '').toLowerCase() === 'true';
const summary = await syncManifestsToDb(prisma, { dryRun, removeManifestAfterImport: remove });
res.json({ dryRun, remove, ...summary });
} catch (error) {
res.status(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';
}
// 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 };
}
// 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());
if (dryRun) {
return res.json({ eventId, matched: recipients.length, 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 Yoco 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 { createRegistrationCheckoutInternal } = require('./paymentController');
const result = await createRegistrationCheckoutInternal(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 finalHtml = h ? await replacePlaceholders(h, ctxHtml) : undefined;
const finalText = (!h ? await replacePlaceholders(t || '', ctxBase) : undefined);
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
}));
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';
}
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 };
}
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 });
}
}
const recipients = Array.from(uniqMap.values());
if (dryRun) {
return res.json({ eventId, matched: recipients.length, 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 Yoco 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 { createRegistrationCheckoutInternal } = require('./paymentController');
const result = await createRegistrationCheckoutInternal(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, 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 });
}
};
// @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 } = 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 };
const { addJob } = require('../utils/scheduledEmails');
const created = addJob({
eventId,
createdById: req.user?.id || null,
scheduledAt: when.toISOString(),
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 } = 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 };
const { addJob } = require('../utils/scheduledEmails');
const created = addJob({
eventId,
channel: 'whatsapp',
createdById: req.user?.id || null,
scheduledAt: when.toISOString(),
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
*/
const getEventByAlias = async (req, res) => {
const { redirectUrl } = req.params;
try {
const event = await prisma.event.findFirst({
where: {
redirectUrl: {
equals: redirectUrl,
mode: 'insensitive',
},
isActive: true,
},
select: {
id: true,
title: true,
description: true,
startDate: true,
endDate: true,
registrationDeadline: true,
goLiveAt: true,
price: true,
picture: true,
isActive: true,
redirectUrl: true,
},
});
if (!event) {
return res.status(404).json({ message: 'Event not found or inactive' });
}
const now = new Date();
const start = event.startDate ? new Date(event.startDate) : null;
const end = event.endDate ? new Date(event.endDate) : null;
const isLive =
(!start || start >= now) &&
(!end || end >= now);
res.json({ ...event, isLive });
} catch (error) {
console.error('[getEventByAlias] Error:', error);
res.status(500).json({ message: 'Internal server error' });
}
};
module.exports = {
createEvent,
getEvents,
getAllEvents,
getEventsAll,
getEventById,
updateEvent,
getEventNotifyRecipients,
updateEventNotifyRecipients,
deleteEvent,
createEventOption,
updateEventOption,
deleteEventOption,
createOptionVariant,
updateOptionVariant,
deleteOptionVariant,
listEventAttachments,
uploadEventAttachment,
deleteEventAttachment,
attachmentsStatus,
attachmentsSync,
emailEventAttendees,
scheduleEmailEventAttendees,
whatsappEventAttendees,
scheduleWhatsappEventAttendees,
getEventByAlias
};