- src/payments: provider registry and PaymentProvider interface (createCheckout, verifyWebhook/parseWebhook, getTransaction, refund, listTransactions as capabilities), Yoco as the first provider. All four direct Yoco checkout calls go through payments.createCheckout. - Webhook split into the provider part (signature, parsing) and shared processing; same URL (/api/webhooks/yoco), same behaviour. - YocoTransaction renamed in place to ProviderTransaction (+ provider, unique per provider + externalId); Payment.provider added and backfilled to "yoco". Payment.method stays the wallet type so cashups are unchanged. - Site settings → Payments: Enabled toggle and order per provider; Yoco's settings shown while it's enabled. R15 minimum for all online payments. - New endpoint names (/api/payments/checkout, /api/provider-transactions); the old /yoco-checkout and /yoco-transactions paths still work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2119 lines
89 KiB
JavaScript
2119 lines
89 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 crypto = require('crypto');
|
|
const { assertEventOpen } = require('../utils/cashupUtils');
|
|
const { logAdminAction } = require('../utils/adminAudit');
|
|
const { getClientIp } = require('../utils/requestUtils');
|
|
const sessionsUtil = require('../utils/sessions');
|
|
|
|
const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' }, include: { _count: { select: { usages: true } } } };
|
|
|
|
// Adds the multi-day schedule to an event for API responses: sessions (cancelled ones
|
|
// flagged), when registration closes, the next session, and late-joiner pricing.
|
|
// `bookings` (Map sessionId → people booked) adds `booked` and `placesLeft` per session.
|
|
function withSchedule(ev, bookings = null) {
|
|
const sessions = (ev.sessions || []).map(({ _count, ...s }) => ({
|
|
...s,
|
|
cancelled: !!s.cancelledAt,
|
|
hasCheckIns: (_count?.usages || 0) > 0,
|
|
...(bookings ? { booked: bookings.get(s.id) || 0, placesLeft: s.capacity > 0 ? Math.max(0, s.capacity - (bookings.get(s.id) || 0)) : null } : {}),
|
|
}));
|
|
const active = sessionsUtil.activeSessions(sessions);
|
|
const now = new Date();
|
|
return {
|
|
...ev,
|
|
sessions,
|
|
registrationClosesAt: sessionsUtil.registrationClosesAt(ev, sessions),
|
|
wholeCourseClosesAt: ev.scheduleType === 'sessions' ? sessionsUtil.wholeCourseClosesAt(ev, sessions) : null,
|
|
nextSession: ev.scheduleType === 'sessions' ? active.find(s => new Date(s.endsAt) > now) || null : null,
|
|
lateJoin: ev.scheduleType === 'sessions' ? sessionsUtil.lateInfo(ev, sessions, now) : null,
|
|
};
|
|
}
|
|
|
|
// The schedule/attendance fields of an event from a create/update body. `current` is the
|
|
// saved event on update (unsent fields keep their value).
|
|
function scheduleData(body, current = {}) {
|
|
const has = (k) => body[k] !== undefined;
|
|
const data = {};
|
|
if (has('scheduleType')) data.scheduleType = body.scheduleType === 'sessions' ? 'sessions' : 'single';
|
|
if (has('attendanceMode')) data.attendanceMode = body.attendanceMode === 'pick' ? 'pick' : 'whole';
|
|
if ((data.attendanceMode ?? current.attendanceMode) === 'pick' && (body.requiresParentalConsent ?? current.requiresParentalConsent) === true) {
|
|
throw sessionsUtil.sessionError("Choosing sessions isn't available on youth events (parental consent).");
|
|
}
|
|
if (has('checkInWindow')) data.checkInWindow = body.checkInWindow === 'session_only' ? 'session_only' : 'whole_day';
|
|
if (has('lateJoinEnabled')) data.lateJoinEnabled = body.lateJoinEnabled === true;
|
|
if (has('latePricing')) data.latePricing = ['prorata', 'fixed'].includes(body.latePricing) ? body.latePricing : 'full';
|
|
if (has('lateFixedPrice')) {
|
|
const n = parseFloat(body.lateFixedPrice);
|
|
data.lateFixedPrice = body.lateFixedPrice === null || body.lateFixedPrice === '' || isNaN(n) ? null : Math.max(0, n);
|
|
}
|
|
if (has('sessionReminders')) data.sessionReminders = sessionsUtil.normalizeReminders(body.sessionReminders) ?? null;
|
|
if (has('certificatesEnabled')) data.certificatesEnabled = body.certificatesEnabled === true;
|
|
if (has('certificateConfig')) data.certificateConfig = sessionsUtil.normalizeCertificateConfig(body.certificateConfig);
|
|
const latePricing = data.latePricing ?? current.latePricing;
|
|
const lateFixedPrice = data.lateFixedPrice !== undefined ? data.lateFixedPrice : current.lateFixedPrice;
|
|
if (latePricing === 'fixed' && (lateFixedPrice === null || lateFixedPrice === undefined) && (data.lateJoinEnabled ?? current.lateJoinEnabled)) {
|
|
throw sessionsUtil.sessionError('Enter the fixed late price, or choose another late price option');
|
|
}
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Saves the session list and the "late joining until" session after an event is created
|
|
* or updated, inside the caller's transaction (`tx`) so a refused change saves nothing.
|
|
* `lateJoinUntilSessionIndex` points into the sent session list (sorted by start).
|
|
* @returns {Promise<object|null>} the change counts, or null when nothing was sent
|
|
*/
|
|
async function saveSchedule(tx, eventId, body) {
|
|
sessionsUtil.forgetSchedule(eventId);
|
|
if (body.scheduleType === 'single') {
|
|
const used = await tx.ticketUsage.count({ where: { session: { eventId } } });
|
|
if (used > 0) throw sessionsUtil.sessionError("This event already has session check-ins, so it can't go back to a single date.");
|
|
await tx.eventSession.deleteMany({ where: { eventId } });
|
|
await tx.event.update({ where: { id: eventId }, data: { lateJoinUntilSessionId: null } });
|
|
return null;
|
|
}
|
|
if (!Array.isArray(body.sessions)) return null;
|
|
const { sessions, changes } = await sessionsUtil.syncEventSessions(tx, eventId, body.sessions);
|
|
if (body.lateJoinUntilSessionIndex !== undefined) {
|
|
const idx = parseInt(body.lateJoinUntilSessionIndex, 10);
|
|
const ordered = [...sessions].sort((a, b) => a.order - b.order);
|
|
const until = Number.isInteger(idx) && ordered[idx] && !ordered[idx].cancelledAt ? ordered[idx].id : null;
|
|
await tx.event.update({ where: { id: eventId }, data: { lateJoinUntilSessionId: until } });
|
|
}
|
|
return changes;
|
|
}
|
|
|
|
// 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, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = 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,
|
|
requiresRegistration: requiresRegistration === false || requiresRegistration === 'false' ? false : true,
|
|
// A youth event needs names (with dates of birth), so it always takes them.
|
|
allowNamedAttendees: allowNamedAttendees === true || allowNamedAttendees === 'true' || requiresParentalConsent === true,
|
|
requiresParentalConsent: requiresParentalConsent === true,
|
|
consentText: consentText ? String(consentText) : null,
|
|
consentCollectMedical: consentCollectMedical === true,
|
|
consentCollectEmergency: consentCollectEmergency === true,
|
|
consentCollectPhoto: consentCollectPhoto === true,
|
|
waitlistEnabled: waitlistEnabled === true,
|
|
allowTicketTransfer: allowTicketTransfer !== false,
|
|
collectGender: collectGender === true,
|
|
allowSponsorships: allowSponsorships === true,
|
|
sponsorshipGoalSpots: sponsorshipGoalSpots ? Math.max(1, parseInt(sponsorshipGoalSpots, 10) || 0) || null : null,
|
|
showSponsorshipProgress: showSponsorshipProgress !== false,
|
|
waitlistOfferHours: Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)),
|
|
waitlistPartialOffers: waitlistPartialOffers !== false,
|
|
contactName: contactName || null,
|
|
contactPhone: contactPhone || null,
|
|
contactEmail: contactEmail || null,
|
|
location: location || null,
|
|
...scheduleData(req.body),
|
|
};
|
|
if (data.scheduleType === 'sessions') {
|
|
// Check the sessions before anything is saved; the dates come from them.
|
|
const bounds = sessionsUtil.scheduleBounds(sessionsUtil.normalizeSessionsInput(req.body.sessions).filter(s => !s.cancelled));
|
|
data.startDate = bounds.startDate;
|
|
data.endDate = bounds.endDate;
|
|
}
|
|
|
|
const logEventCreate = (createdEvent) => logAdminAction({
|
|
actorId: req.user?.id,
|
|
actorRole: req.user?.role,
|
|
action: 'event_created',
|
|
targetType: 'Event',
|
|
targetId: createdEvent.id,
|
|
metadata: { title: createdEvent.title },
|
|
ip: getClientIp(req),
|
|
});
|
|
|
|
try {
|
|
const event = await prisma.event.create({ data });
|
|
logEventCreate(event);
|
|
if (data.scheduleType === 'sessions') await prisma.$transaction(tx => saveSchedule(tx, event.id, req.body));
|
|
|
|
// Automatically create a main ticket (event option) with the event price — contact-only
|
|
// events have no bookable options, so there's nothing to auto-create for them.
|
|
try {
|
|
if (data.requiresRegistration !== false && 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(withSchedule(await prisma.event.findUnique({ where: { id: event.id }, include: { sessions: SESSIONS_INCLUDE } })));
|
|
} 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 });
|
|
logEventCreate(event);
|
|
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 });
|
|
logEventCreate(event);
|
|
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 });
|
|
logEventCreate(event);
|
|
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 });
|
|
logEventCreate(event);
|
|
return res.status(201).json(event);
|
|
}
|
|
throw err;
|
|
}
|
|
} catch (error) {
|
|
res.status(error.statusCode || 400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// Map of eventId → when its early bird ends, for events with at least one early-bird
|
|
// tier still running: deadline not passed and (no stock limit, or places left, counted
|
|
// across tranches the same way as utils/pricing.js). The date is the latest deadline
|
|
// among the running tiers.
|
|
async function earlyBirdEndsByEvent(events) {
|
|
const now = new Date();
|
|
const running = [];
|
|
for (const ev of events) {
|
|
for (const opt of ev.eventOptions || []) {
|
|
for (const tier of opt.earlyBirdTiers || []) {
|
|
if (new Date(tier.deadline) > now) running.push({ eventId: ev.id, tier });
|
|
}
|
|
}
|
|
}
|
|
|
|
const limitedIds = running.filter(r => r.tier.stockLimit > 0).map(r => r.tier.id);
|
|
const sold = new Map();
|
|
if (limitedIds.length > 0) {
|
|
const totals = await prisma.registrationOptionTranche.groupBy({
|
|
by: ['appliedTierId'],
|
|
where: {
|
|
appliedTierId: { in: limitedIds },
|
|
registrationOption: { registration: { status: { not: 'cancelled' } } }
|
|
},
|
|
_sum: { quantity: true }
|
|
});
|
|
for (const t of totals) sold.set(t.appliedTierId, t._sum?.quantity || 0);
|
|
}
|
|
|
|
const ends = new Map();
|
|
for (const { eventId, tier } of running) {
|
|
if (tier.stockLimit > 0 && (sold.get(tier.id) || 0) >= tier.stockLimit) continue;
|
|
const deadline = new Date(tier.deadline);
|
|
const current = ends.get(eventId);
|
|
if (!current || deadline > current) ends.set(eventId, deadline);
|
|
}
|
|
return ends;
|
|
}
|
|
|
|
// @desc Get all events
|
|
// @route GET /api/events
|
|
// @access Public
|
|
const getEvents = async (req, res) => {
|
|
try {
|
|
let events;
|
|
// Determine if Prisma Client supports EarlyBirdTier relation (i.e., migration+generate applied)
|
|
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
|
const eventOptionsInclude = canIncludeTiers ? { include: { earlyBirdTiers: true } } : true;
|
|
try {
|
|
events = await prisma.event.findMany({
|
|
where: {
|
|
isActive: true,
|
|
isHidden: false,
|
|
goLiveAt: { lte: new Date() },
|
|
endDate: { gte: new Date() }
|
|
},
|
|
include: { eventOptions: eventOptionsInclude, sessions: SESSIONS_INCLUDE }
|
|
});
|
|
} catch (e) {
|
|
// Fallback if goLiveAt/isHidden not available yet (pre-migration)
|
|
events = await prisma.event.findMany({
|
|
where: {
|
|
isActive: true,
|
|
endDate: { gte: new Date() }
|
|
},
|
|
include: { eventOptions: eventOptionsInclude }
|
|
});
|
|
}
|
|
|
|
const earlyBirdEnds = await earlyBirdEndsByEvent(events).catch(() => new Map());
|
|
|
|
// Compute isSoldOut per event: true when every option that has a stock limit
|
|
// is fully sold out. Events with no limited options are never sold out.
|
|
const withStock = await Promise.all(events.map(async ev => {
|
|
const earlyBirdEndsAt = earlyBirdEnds.get(ev.id) || null;
|
|
try {
|
|
// Picked-session options have per-session limits, so they never make the event sold out.
|
|
const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0 && !sessionsUtil.isPickOption(ev, o));
|
|
let isSoldOut = false;
|
|
if (limitedOpts.length > 0) {
|
|
const { takenCount } = require('../utils/stock');
|
|
const soldCounts = await Promise.all(limitedOpts.map(opt => takenCount({ eventOptionId: opt.id })));
|
|
isSoldOut = limitedOpts.every((opt, i) => soldCounts[i] >= opt.stockLimit);
|
|
}
|
|
return withSchedule({ ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut, earlyBirdEndsAt });
|
|
} catch {
|
|
return withSchedule({ ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut: false, earlyBirdEndsAt });
|
|
}
|
|
}));
|
|
|
|
res.json(withStock);
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Get all events (including inactive)
|
|
// @route GET /api/events/all
|
|
// @access Private/Admin
|
|
const getAllEvents = async (req, res) => {
|
|
try {
|
|
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
|
const events = await prisma.event.findMany({
|
|
include: {
|
|
eventOptions: canIncludeTiers ? { include: { earlyBirdTiers: true } } : true,
|
|
sessions: SESSIONS_INCLUDE,
|
|
}
|
|
});
|
|
|
|
const mapped = events.map(ev => withSchedule({
|
|
...ev,
|
|
picture: toAbsoluteUrl(req, ev.picture),
|
|
}));
|
|
|
|
res.json(mapped);
|
|
} catch (error) {
|
|
// If prisma client doesn't support the relation include (rare), retry without it
|
|
const events = await prisma.event.findMany({ include: { eventOptions: true } });
|
|
const mapped = events.map(ev => ({ ...ev, picture: toAbsoluteUrl(req, ev.picture) }));
|
|
res.json(mapped);
|
|
}
|
|
};
|
|
|
|
// @desc Get all events for staff/supervisor/admin (always includes hidden; optional past/inactive)
|
|
// @route GET /api/events/all
|
|
// @access Private
|
|
const getEventsAll = async (req, res) => {
|
|
try {
|
|
const 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 } },
|
|
sessions: SESSIONS_INCLUDE,
|
|
},
|
|
orderBy: { startDate: 'asc' }
|
|
});
|
|
|
|
const mapped = events.map(ev => withSchedule({
|
|
...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, sessions: SESSIONS_INCLUDE };
|
|
|
|
// 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 => {
|
|
// Taken = non-cancelled registrations + places held for live waitlist offers
|
|
const { takenCount } = require('../utils/stock');
|
|
// Picked-session options: the stock limit is places per session.
|
|
if (sessionsUtil.isPickOption(event, opt)) {
|
|
const sessionAvailability = {};
|
|
if (opt.stockLimit > 0) {
|
|
for (const s of sessionsUtil.activeSessions(event.sessions)) {
|
|
sessionAvailability[s.id] = Math.max(0, opt.stockLimit - await takenCount({ eventOptionId: opt.id, sessionId: s.id }));
|
|
}
|
|
}
|
|
return { ...opt, soldCount: 0, availableCount: null, sessionAvailability };
|
|
}
|
|
const soldCount = await takenCount({ eventOptionId: opt.id });
|
|
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 vSold = await takenCount({ eventOptionId: opt.id, variantId: v.id });
|
|
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
|
|
}
|
|
|
|
if (!event.sessions) event.sessions = await prisma.eventSession.findMany({ where: { eventId }, ...SESSIONS_INCLUDE }).catch(() => []);
|
|
const bookings = event.scheduleType === 'sessions' ? await sessionsUtil.sessionBookings(eventId).catch(() => null) : null;
|
|
const mapped = withSchedule({
|
|
...event,
|
|
eventOptions: eventOptionsWithStock,
|
|
picture: toAbsoluteUrl(req, event.picture),
|
|
attachments: (attachments || []).map(att => ({
|
|
...att,
|
|
url: toAbsoluteUrl(req, att.url)
|
|
})),
|
|
form,
|
|
bundles: await require('./bundleController').publicBundlesForEvent(eventId).catch(() => []),
|
|
}, bookings);
|
|
res.json(mapped);
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Download a .ics calendar file for an event
|
|
// @route GET /api/events/:id/ics
|
|
// @access Public (same visibility gating as getEventById)
|
|
const getEventIcs = async (req, res) => {
|
|
try {
|
|
const eventId = req.params.id;
|
|
const event = await prisma.event.findUnique({
|
|
where: { id: eventId },
|
|
select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true, scheduleType: true, sessions: { orderBy: { startsAt: 'asc' } } },
|
|
});
|
|
|
|
if (!event) {
|
|
res.status(404);
|
|
throw new Error('Event not found');
|
|
}
|
|
|
|
// Same public-visibility gate as getEventById — a hidden/not-yet-live event's
|
|
// details shouldn't be enumerable via this route either.
|
|
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');
|
|
}
|
|
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');
|
|
}
|
|
}
|
|
|
|
const { buildEventIcs } = require('../utils/icsUtils');
|
|
let frontendUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
|
// The ics library's url validator requires a scheme; guard against FRONTEND_URL/APP_BASE_URL
|
|
// being configured without one (e.g. "example.com" instead of "https://example.com").
|
|
if (!/^[a-z0-9+.-]+:\/\//i.test(frontendUrl)) {
|
|
frontendUrl = `https://${frontendUrl}`;
|
|
}
|
|
const ics = buildEventIcs(event, `${frontendUrl}/events/${event.id}`);
|
|
|
|
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
|
res.setHeader('Content-Disposition', `attachment; filename="${event.title.replace(/[^a-z0-9 -]/gi, '').slice(0, 60) || 'event'}.ics"`);
|
|
res.send(ics);
|
|
} 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 logEventUpdate = () => logAdminAction({
|
|
actorId: req.user.id,
|
|
actorRole: req.user.role,
|
|
action: 'event_updated',
|
|
targetType: 'Event',
|
|
targetId: req.params.id,
|
|
metadata: { changedKeys: Object.keys(req.body || {}) },
|
|
ip: getClientIp(req),
|
|
});
|
|
|
|
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = 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),
|
|
requiresRegistration: requiresRegistration !== undefined ? !(requiresRegistration === false || requiresRegistration === 'false') : (event.requiresRegistration ?? true),
|
|
allowNamedAttendees: (requiresParentalConsent ?? event.requiresParentalConsent) === true
|
|
? true
|
|
: (allowNamedAttendees !== undefined ? (allowNamedAttendees === true || allowNamedAttendees === 'true') : event.allowNamedAttendees),
|
|
requiresParentalConsent: requiresParentalConsent !== undefined ? requiresParentalConsent === true : event.requiresParentalConsent,
|
|
consentCollectMedical: consentCollectMedical !== undefined ? consentCollectMedical === true : event.consentCollectMedical,
|
|
consentCollectEmergency: consentCollectEmergency !== undefined ? consentCollectEmergency === true : event.consentCollectEmergency,
|
|
consentCollectPhoto: consentCollectPhoto !== undefined ? consentCollectPhoto === true : event.consentCollectPhoto,
|
|
waitlistEnabled: waitlistEnabled !== undefined ? waitlistEnabled === true : event.waitlistEnabled,
|
|
allowTicketTransfer: allowTicketTransfer !== undefined ? allowTicketTransfer !== false : event.allowTicketTransfer,
|
|
collectGender: collectGender !== undefined ? collectGender === true : event.collectGender,
|
|
allowSponsorships: allowSponsorships !== undefined ? allowSponsorships === true : event.allowSponsorships,
|
|
sponsorshipGoalSpots: sponsorshipGoalSpots !== undefined ? (sponsorshipGoalSpots ? Math.max(1, parseInt(sponsorshipGoalSpots, 10) || 0) || null : null) : event.sponsorshipGoalSpots,
|
|
showSponsorshipProgress: showSponsorshipProgress !== undefined ? showSponsorshipProgress !== false : event.showSponsorshipProgress,
|
|
waitlistOfferHours: waitlistOfferHours !== undefined ? Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)) : event.waitlistOfferHours,
|
|
waitlistPartialOffers: waitlistPartialOffers !== undefined ? waitlistPartialOffers !== false : event.waitlistPartialOffers,
|
|
// Editing the consent text starts a new version; parents who already agreed keep
|
|
// their record of the old one (supervisors can re-request consent).
|
|
...(consentText !== undefined && String(consentText || '') !== String(event.consentText || '')
|
|
? { consentText: consentText ? String(consentText) : null, consentTextVersion: (event.consentTextVersion || 1) + 1 }
|
|
: {}),
|
|
contactName: contactName !== undefined ? (contactName || null) : event.contactName,
|
|
contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone,
|
|
contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail,
|
|
location: location !== undefined ? (location || null) : event.location,
|
|
updatedAt: new Date(),
|
|
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
|
|
...scheduleData(req.body, event),
|
|
};
|
|
// A multi-session event's dates come from its sessions (set by saveSchedule).
|
|
if ((data.scheduleType ?? event.scheduleType) === 'sessions') {
|
|
data.startDate = event.startDate;
|
|
data.endDate = event.endDate;
|
|
if (Array.isArray(req.body.sessions)) sessionsUtil.normalizeSessionsInput(req.body.sessions);
|
|
else if (event.scheduleType !== 'sessions') throw sessionsUtil.sessionError('Add the sessions for a multi-session event');
|
|
}
|
|
|
|
try {
|
|
// Event fields and sessions save together: a refused schedule change saves nothing.
|
|
const scheduleBody = data.scheduleType === 'single' && event.scheduleType === 'sessions' ? { scheduleType: 'single' } : req.body;
|
|
let updatedEvent;
|
|
let sessionChanges;
|
|
await prisma.$transaction(async (tx) => {
|
|
updatedEvent = await tx.event.update({ where: { id: req.params.id }, data });
|
|
sessionChanges = updatedEvent.scheduleType === 'sessions' || scheduleBody.scheduleType === 'single'
|
|
? await saveSchedule(tx, updatedEvent.id, scheduleBody)
|
|
: null;
|
|
});
|
|
sessionsUtil.forgetSchedule(updatedEvent.id);
|
|
if (sessionChanges && Object.values(sessionChanges).some(n => n > 0)) {
|
|
logAdminAction({
|
|
actorId: req.user.id, actorRole: req.user.role, action: 'event_sessions_changed',
|
|
targetType: 'Event', targetId: updatedEvent.id, metadata: sessionChanges, ip: getClientIp(req),
|
|
});
|
|
}
|
|
updatedEvent = withSchedule(await prisma.event.findUnique({ where: { id: updatedEvent.id }, include: { sessions: SESSIONS_INCLUDE } }));
|
|
updatedEvent.sessionChanges = sessionChanges;
|
|
|
|
|
|
// 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.' });
|
|
}
|
|
|
|
logEventUpdate();
|
|
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 });
|
|
logEventUpdate();
|
|
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 });
|
|
logEventUpdate();
|
|
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()
|
|
}
|
|
});
|
|
|
|
logAdminAction({
|
|
actorId: req.user.id,
|
|
actorRole: req.user.role,
|
|
action: 'event_deleted',
|
|
targetType: 'Event',
|
|
targetId: req.params.id,
|
|
ip: getClientIp(req),
|
|
});
|
|
|
|
res.json({ message: 'Event deactivated' });
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// Optional staff price from a request body: '' / null clears it, undefined leaves it.
|
|
function parseStaffPrice(value, current = null) {
|
|
if (value === undefined) return current;
|
|
if (value === null || value === '') return null;
|
|
const n = parseFloat(value);
|
|
if (!(n >= 0)) throw new Error('Staff price must be zero or more');
|
|
return n;
|
|
}
|
|
|
|
// @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, staffPrice, sessionMode } = 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,
|
|
staffPrice: parseStaffPrice(staffPrice),
|
|
sessionMode: sessionMode === 'pick' ? 'pick' : 'all',
|
|
}
|
|
});
|
|
sessionsUtil.forgetSchedule(req.params.id);
|
|
|
|
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, staffPrice, sessionMode } = 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,
|
|
staffPrice: parseStaffPrice(staffPrice, eventOption.staffPrice),
|
|
sessionMode: sessionMode !== undefined ? (sessionMode === 'pick' ? 'pick' : 'all') : eventOption.sessionMode,
|
|
}
|
|
});
|
|
|
|
// If earlyBirdTiers provided, upsert tiers for this option (including per-variant ones) by
|
|
// id — mirrors the variants upsert just below. Editing tiers used to delete-and-recreate
|
|
// every tier for the option on every save, which silently reassigned each one a brand-new
|
|
// id; RegistrationOption/RegistrationOptionTranche.appliedTierId (onDelete: SetNull) then
|
|
// pointed at nothing, so every past purchase under that tier lost its "early bird"
|
|
// attribution — even for tiers the admin didn't touch — and any stock-limit count for the
|
|
// tier reset to zero (allowing it to be oversold). Upserting by id keeps existing tiers'
|
|
// ids stable across edits so that history stays linked.
|
|
if (Array.isArray(earlyBirdTiers)) {
|
|
const incomingIds = earlyBirdTiers.filter(t => t && t.id).map(t => t.id);
|
|
const existingTiers = await prisma.earlyBirdTier.findMany({ where: { eventOptionId: updatedEventOption.id } });
|
|
for (const et of existingTiers) {
|
|
if (incomingIds.includes(et.id)) continue;
|
|
// Never delete a tier that's still attributed on past purchases — just leave it
|
|
// orphaned from the option's active tier list rather than nulling out history.
|
|
const usageCount = await prisma.registrationOptionTranche.count({ where: { appliedTierId: et.id } });
|
|
if (usageCount === 0) {
|
|
try { await prisma.earlyBirdTier.delete({ where: { id: et.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;
|
|
const data = {
|
|
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 (t.id) {
|
|
await prisma.earlyBirdTier.upsert({
|
|
where: { id: t.id },
|
|
update: data,
|
|
create: { id: t.id, ...data },
|
|
});
|
|
} else {
|
|
await prisma.earlyBirdTier.create({ data: { id: require('uuid').v4(), ...data } });
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
const variantStaffPrice = parseStaffPrice(v.staffPrice);
|
|
if (v.id) {
|
|
await prisma.optionVariant.update({
|
|
where: { id: v.id },
|
|
data: { name: v.name, price: variantPrice, stockLimit: variantStockLimit, staffPrice: variantStaffPrice, order: i }
|
|
});
|
|
} else {
|
|
await prisma.optionVariant.create({
|
|
data: {
|
|
id: require('uuid').v4(),
|
|
eventOptionId: updatedEventOption.id,
|
|
name: v.name,
|
|
price: variantPrice,
|
|
stockLimit: variantStockLimit,
|
|
staffPrice: variantStaffPrice,
|
|
order: i,
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (stockLimit !== undefined || variants !== undefined) {
|
|
require('../utils/waitlist').queueWaitlist(eventOption.eventId);
|
|
}
|
|
|
|
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, staffPrice } = 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,
|
|
staffPrice: parseStaffPrice(staffPrice),
|
|
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, staffPrice } = req.body;
|
|
const updated = await prisma.optionVariant.update({
|
|
where: { id: req.params.id },
|
|
data: {
|
|
name: name !== undefined ? name : variant.name,
|
|
staffPrice: parseStaffPrice(staffPrice, variant.staffPrice),
|
|
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) {
|
|
// Extension only — file.originalname is untrusted and joining it into a
|
|
// path allows `../` traversal to write outside the upload directory.
|
|
const ext = path.extname(file.originalname).toLowerCase();
|
|
const unique = `event-file-${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`;
|
|
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';
|
|
}
|
|
|
|
// Only registrations with someone in this allocation group ("everyone on Bus 2").
|
|
if (filter?.allocationGroupId) {
|
|
where.attendees = { some: { allocations: { some: { groupId: String(filter.allocationGroupId) } } } };
|
|
}
|
|
|
|
// Optional explicit attendee selection by userIds
|
|
const attendeeIds = Array.isArray(filter?.attendeeIds) ? filter.attendeeIds.filter((x) => typeof x === 'string' && x.trim().length > 0) : [];
|
|
if (attendeeIds.length > 0) {
|
|
// Prefer server-side filtering when possible
|
|
where.userId = { in: attendeeIds };
|
|
}
|
|
|
|
// Name/email substring filter (kept for backward compatibility)
|
|
const nameQ = (filter && filter.name) ? String(filter.name).trim() : '';
|
|
const emailQ = (filter && filter.email) ? String(filter.email).trim() : '';
|
|
|
|
const registrations = await prisma.registration.findMany({
|
|
where,
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true, isActive: true } },
|
|
// Include options with eventOption and early-bird tiers so pricing/balances compute correctly
|
|
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
|
payments: true,
|
|
},
|
|
orderBy: { createdAt: 'asc' }
|
|
});
|
|
|
|
// Apply name/email filter in JS (Prisma cross-relational OR contains with guards varies across versions)
|
|
// Also exclude guest/system-generated emails and suspended users
|
|
let filtered = registrations.filter(r => {
|
|
if (!r.user || !r.user.email) return false;
|
|
if (r.user.email.endsWith('@guest.local')) return false;
|
|
if (r.user.isActive === false) return false;
|
|
return true;
|
|
});
|
|
if (nameQ) {
|
|
const q = nameQ.toLowerCase();
|
|
filtered = filtered.filter(r => (r.user?.name || '').toLowerCase().includes(q));
|
|
}
|
|
if (emailQ) {
|
|
const q = emailQ.toLowerCase();
|
|
filtered = filtered.filter(r => (r.user?.email || '').toLowerCase().includes(q));
|
|
}
|
|
|
|
// Unique recipients by email to avoid duplicates across multiple registrations
|
|
const uniqMap = new Map();
|
|
for (const r of filtered) {
|
|
const email = (r.user?.email || '').trim();
|
|
if (!email) continue;
|
|
if (!uniqMap.has(email)) {
|
|
uniqMap.set(email, { email, name: r.user?.name || '' });
|
|
}
|
|
}
|
|
const recipients = Array.from(uniqMap.values());
|
|
|
|
// Sponsors of this event instead of its attendees (e.g. the post-event thank-you).
|
|
if (filter?.audience === 'sponsors') {
|
|
recipients.length = 0;
|
|
registrations.length = 0;
|
|
const sponsorships = await prisma.payment.findMany({
|
|
where: { eventId, isDonation: true, isSponsorship: true, amount: { gt: 0 } },
|
|
include: { user: { select: { name: true, email: true, isActive: true } } },
|
|
orderBy: { createdAt: 'asc' },
|
|
});
|
|
const seen = new Set();
|
|
for (const s of sponsorships) {
|
|
const email = (s.user?.email || '').trim();
|
|
if (!email || email.endsWith('@guest.local') || email.endsWith('@deleted.invalid') || seen.has(email)) continue;
|
|
seen.add(email);
|
|
recipients.push({ email, name: s.sponsorName || s.user?.name || '' });
|
|
}
|
|
}
|
|
|
|
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 checkout link for the recipient's first registration with an
|
|
// outstanding balance — only called when the template actually uses {{payment.link}}, to
|
|
// avoid an unnecessary Yoco API call per recipient otherwise.
|
|
function makePaymentLinkResolver(regs) {
|
|
return async () => {
|
|
const reg = (regs || []).find(r => {
|
|
const due = computeRegistrationTotalDue(r, new Date());
|
|
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
|
return due - paid > 0.01;
|
|
});
|
|
if (!reg) return '';
|
|
const { createRegistrationCheckout } = require('../payments/checkouts');
|
|
const result = await createRegistrationCheckout(reg.id, reg.userId, {
|
|
successUrl: `${baseUrl}/payment/success`,
|
|
cancelUrl: `${baseUrl}/payment/cancel`,
|
|
failureUrl: `${baseUrl}/payment/failure`,
|
|
});
|
|
return result.redirectUrl;
|
|
};
|
|
}
|
|
|
|
// Fire-and-forget: respond immediately with a queued count, then send in the background.
|
|
// Large recipient lists used to block the request until every email was sent — now the
|
|
// caller gets an instant response and failures are just logged server-side.
|
|
if (template === 'tickets') {
|
|
const { emailTickets } = require('./ticketController');
|
|
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: 'tickets' });
|
|
(async () => {
|
|
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
|
const regs = regsByEmail.get(rcpt.email) || [];
|
|
for (const reg of regs) {
|
|
const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } };
|
|
const mockRes = { status: () => mockRes, json: () => {} };
|
|
await emailTickets(mockReq, mockRes);
|
|
}
|
|
}));
|
|
results.forEach((r, i) => {
|
|
if (r.status === 'rejected') {
|
|
try { console.warn('[email-attendees tickets] Failed for', recipients[i]?.email, r.reason?.message || r.reason); } catch {}
|
|
}
|
|
});
|
|
})();
|
|
return;
|
|
}
|
|
|
|
const eventTitle = event?.title || 'the event';
|
|
const eventStart = event?.startDate ? fmtDate(event.startDate) : '';
|
|
// Build event and promo links for placeholders
|
|
const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`;
|
|
const eventLinkHtml = `<a href="${eventLink}">${eventLink}</a>`;
|
|
const promoTitle = promoEvent?.title || '';
|
|
const promoLink = promoEvent ? `${baseUrl}/events/${encodeURIComponent(promoEvent.id)}` : '';
|
|
const promoLinkHtml = promoLink ? `<a href="${promoLink}">${promoLink}</a>` : '';
|
|
|
|
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: template || 'custom' });
|
|
|
|
(async () => {
|
|
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
|
const regs = regsByEmail.get(rcpt.email) || [];
|
|
// Sum outstanding balance across this user's registrations for the event
|
|
let totalDue = 0; let totalPaid = 0;
|
|
for (const r of regs) {
|
|
const due = computeRegistrationTotalDue(r, new Date());
|
|
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
|
totalDue += due; totalPaid += paid;
|
|
}
|
|
const balance = Math.max(totalDue - totalPaid, 0);
|
|
const paymentLinkResolver = makePaymentLinkResolver(regs);
|
|
// Build context for placeholder replacement
|
|
const ctxBase = {
|
|
name: rcpt.name || '',
|
|
eventTitle,
|
|
eventStart,
|
|
eventLink,
|
|
promoTitle,
|
|
promoLink,
|
|
balance,
|
|
balanceFmt: fmtAmount(balance),
|
|
paymentLinkResolver,
|
|
};
|
|
const ctxHtml = {
|
|
...ctxBase,
|
|
eventLinkHtml,
|
|
promoLinkHtml,
|
|
};
|
|
|
|
// If a known template is selected and no explicit content provided, auto-build content
|
|
let subj = subject;
|
|
let h = html; let t = text;
|
|
if (!h && !t && template) {
|
|
if (template === 'payment_reminder') {
|
|
subj = `Payment reminder: ${eventTitle}`;
|
|
t = `Hi {{name}}\n\nThis is a friendly reminder that you have an outstanding balance of {{balance}} for {{event.title}}.\nEvent starts: {{event.start}}\n\nPlease settle your balance to secure your tickets. Thank you!`;
|
|
h = `
|
|
<div style="font-family:Arial,sans-serif;line-height:1.6;color:#333">
|
|
<h2>Payment reminder</h2>
|
|
<p>Hi {{name}},</p>
|
|
<p>You have an outstanding balance of <strong>{{balance}}</strong> for <strong>{{event.title}}</strong>.</p>
|
|
${eventStart ? `<p><strong>Event starts:</strong> {{event.start}}</p>` : ''}
|
|
<p>Please settle your balance to secure your tickets. Thank you!</p>
|
|
</div>`;
|
|
} else if (template === 'event_reminder') {
|
|
subj = `Reminder: ${eventTitle} on ${eventStart || ''}`.trim();
|
|
t = `Hi {{name}}\n\nA quick reminder about {{event.title}}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`;
|
|
h = `
|
|
<div style="font-family:Arial,sans-serif;line-height:1.6;color:#333">
|
|
<h2>Event reminder</h2>
|
|
<p>Hi {{name}},</p>
|
|
<p>This is a reminder for <strong>{{event.title}}</strong>.</p>
|
|
${eventStart ? `<p><strong>Start:</strong> {{event.start}}</p>` : ''}
|
|
<p>We look forward to seeing you!</p>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
// Always perform placeholder replacement on whatever we have
|
|
const finalSubject = await replacePlaceholders(subj || '', ctxBase);
|
|
const 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';
|
|
}
|
|
|
|
// Only registrations with someone in this allocation group ("everyone on Bus 2").
|
|
if (filter?.allocationGroupId) {
|
|
where.attendees = { some: { allocations: { some: { groupId: String(filter.allocationGroupId) } } } };
|
|
}
|
|
|
|
const attendeeIds = Array.isArray(filter?.attendeeIds) ? filter.attendeeIds.filter((x) => typeof x === 'string' && x.trim().length > 0) : [];
|
|
if (attendeeIds.length > 0) {
|
|
where.userId = { in: attendeeIds };
|
|
}
|
|
|
|
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 checkout link for the recipient's first registration with an
|
|
// outstanding balance — only called when the template actually uses {{payment.link}}.
|
|
function makePaymentLinkResolver(regs) {
|
|
return async () => {
|
|
const reg = (regs || []).find(r => {
|
|
const due = computeRegistrationTotalDue(r, new Date());
|
|
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
|
return due - paid > 0.01;
|
|
});
|
|
if (!reg) return '';
|
|
const { createRegistrationCheckout } = require('../payments/checkouts');
|
|
const result = await createRegistrationCheckout(reg.id, reg.userId, {
|
|
successUrl: `${baseUrl}/payment/success`,
|
|
cancelUrl: `${baseUrl}/payment/cancel`,
|
|
failureUrl: `${baseUrl}/payment/failure`,
|
|
});
|
|
return result.redirectUrl;
|
|
};
|
|
}
|
|
|
|
// Fire-and-forget: respond immediately with a queued count, then send in the background.
|
|
if (template === 'tickets') {
|
|
const { emailTickets } = require('./ticketController');
|
|
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: 'tickets' });
|
|
(async () => {
|
|
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
|
const regs = regsByPhone.get(rcpt.phone) || [];
|
|
for (const reg of regs) {
|
|
const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } };
|
|
const mockRes = { status: () => mockRes, json: () => {} };
|
|
await emailTickets(mockReq, mockRes);
|
|
}
|
|
}));
|
|
results.forEach((r, i) => {
|
|
if (r.status === 'rejected') {
|
|
try { console.warn('[whatsapp-attendees tickets] Failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {}
|
|
}
|
|
});
|
|
})();
|
|
return;
|
|
}
|
|
|
|
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: template || 'custom' });
|
|
|
|
(async () => {
|
|
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
|
const regs = regsByPhone.get(rcpt.phone) || [];
|
|
let totalDue = 0; let totalPaid = 0;
|
|
for (const r of regs) {
|
|
const due = computeRegistrationTotalDue(r, new Date());
|
|
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
|
totalDue += due; totalPaid += paid;
|
|
}
|
|
const balance = Math.max(totalDue - totalPaid, 0);
|
|
const ctx = {
|
|
name: rcpt.name || '',
|
|
eventTitle,
|
|
eventStart,
|
|
eventLink,
|
|
balance,
|
|
balanceFmt: fmtAmount(balance),
|
|
paymentLinkResolver: makePaymentLinkResolver(regs),
|
|
};
|
|
|
|
let msg = message;
|
|
if (!msg && template === 'payment_reminder') {
|
|
msg = `Hi {{name}}\n\nThis is a friendly reminder that you have an outstanding balance of {{balance}} for {{event.title}}.\nEvent starts: {{event.start}}\n\nPlease settle your balance to secure your tickets. Thank you!`;
|
|
} else if (!msg && template === 'event_reminder') {
|
|
msg = `Hi {{name}}\n\nA quick reminder about {{event.title}}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`;
|
|
}
|
|
|
|
const finalMessage = await replacePlaceholders(msg || '', ctx);
|
|
await sendText(rcpt.phone, 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
|
|
*/
|
|
// Aliases are admin-set slugs (e.g. "camp-2025") — see the event wizard's "URL
|
|
// Alias" field. This endpoint is public and also the target of the frontend's
|
|
// catch-all [redirectUrl] route, so it's what every bot/scanner probe hitting
|
|
// an unmatched top-level path (/wp-login.php, /.env, etc.) ends up calling.
|
|
// Rejecting non-slug-shaped values here skips a DB round-trip for that traffic.
|
|
const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/;
|
|
|
|
const getEventByAlias = async (req, res) => {
|
|
const { redirectUrl } = req.params;
|
|
|
|
if (!VALID_ALIAS.test(redirectUrl)) {
|
|
return res.status(404).json({ message: 'Event not found' });
|
|
}
|
|
|
|
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,
|
|
getEventIcs,
|
|
updateEvent,
|
|
getEventNotifyRecipients,
|
|
updateEventNotifyRecipients,
|
|
deleteEvent,
|
|
createEventOption,
|
|
updateEventOption,
|
|
deleteEventOption,
|
|
createOptionVariant,
|
|
updateOptionVariant,
|
|
deleteOptionVariant,
|
|
listEventAttachments,
|
|
uploadEventAttachment,
|
|
deleteEventAttachment,
|
|
attachmentsStatus,
|
|
attachmentsSync,
|
|
emailEventAttendees,
|
|
scheduleEmailEventAttendees,
|
|
whatsappEventAttendees,
|
|
scheduleWhatsappEventAttendees,
|
|
getEventByAlias
|
|
}; |