Files
hope-events/backend/src/controllers/eventController.js
T
joshuaandClaude Sonnet 5 b3ff2b9c5e Fix scheduled WhatsApp messages sending as email; add 24h cleanup and recipient display
The scheduled-job store never persisted the channel field, so the
send worker always fell through to its email branch regardless of
what was requested. Also purges sent jobs 24h after sending instead
of keeping them forever, and surfaces who each scheduled job will go
to in the admin "manage scheduled" lists (now correctly filtered per
channel too).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 23:37:35 +02:00

1736 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 });
}
};
// 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 } = 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(),
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 } = 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(),
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
*/
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
};