Financial correctness (donation-leg model):
- Donations are no longer mutated when assigned to a registration; assignment now
creates an immutable "leg" record referencing the original donation instead.
- Fixed several places where money was double-counted once a donation was partially
or fully assigned (Payments, Revenue summary, Cashup reconciliation, Finance
report, Profit report, Master Orders, Revenue Detailed).
- Payments now record who recorded them (recordedBy), separate from who they're for.
Cashup:
- Per-user cash denomination counting (optional, any time) replaces the single
event-wide manual entry; the event's cash actual is the live sum of these counts.
- New "Payment accountability by staff member" breakdown across all methods, and a
read-only "Report" tab that opens automatically once an event is closed.
Reports page redesign:
- New shell: sidebar of universal filters (events, date range, past/inactive/closed
toggles), searchable/categorized report grid, and a popup viewer with
Print/Email/Excel/WhatsApp actions plus an in-app Reporting Guide.
- Visual pass: colored stat tiles and bar charts on most reports, matching mockups.
- PDF exports (download/Print/Email/WhatsApp) now share a branded design mirroring
the web report — colored header, stat tiles, bar chart, highlighted totals.
- Excel export now produces a styled .xlsx (via exceljs) instead of a plain CSV.
- Master Orders' "Donations made" table is now included in every export channel.
Bug fixes discovered while testing exports:
- Report emails now go through the shared, DB-configurable mail utility instead of
a one-off transporter that ignored Site Settings SMTP config.
- WhatsApp report sends now surface the actual WAWP API error and auto-recover a
disconnected session, instead of a bare axios status-code message.
Also: Admin-editable notification preference, richer Admin Registrations dashboard,
{{payment.link}} placeholder for Email/WhatsApp Attendees, and background
email/WhatsApp attendee sending.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1725 lines
67 KiB
JavaScript
1725 lines
67 KiB
JavaScript
const prisma = require('../config/db');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const multer = require('multer');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const { assertEventOpen } = require('../utils/cashupUtils');
|
|
|
|
// Helper to convert stored picture path/URL to an absolute, externally reachable URL based on the incoming request
|
|
function toAbsoluteUrl(req, url) {
|
|
if (!url) return url;
|
|
try {
|
|
const raw = String(url).trim();
|
|
if (!raw) return null;
|
|
// If already absolute
|
|
if (raw.startsWith('http://') || raw.startsWith('https://')) {
|
|
const u = new URL(raw);
|
|
// Rewrite localhost to the actual request host
|
|
if (['localhost', '127.0.0.1', '::1'].includes(u.hostname)) {
|
|
const origin = `${req.protocol}://${req.get('host')}`;
|
|
const base = new URL(origin);
|
|
u.protocol = base.protocol;
|
|
u.hostname = base.hostname;
|
|
u.port = base.port;
|
|
return u.toString();
|
|
}
|
|
return raw; // keep as-is if already absolute and not localhost
|
|
}
|
|
// Relative path like /uploads/... => prefix with request origin
|
|
if (raw.startsWith('/')) {
|
|
return `${req.protocol}://${req.get('host')}${raw}`;
|
|
}
|
|
// Any other case, return as-is
|
|
return raw;
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
// @desc Create a new event
|
|
// @route POST /api/events
|
|
// @access Private/Admin
|
|
const createEvent = async (req, res) => {
|
|
try {
|
|
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth } = req.body;
|
|
|
|
const data = {
|
|
id: uuidv4(),
|
|
title,
|
|
description,
|
|
startDate: new Date(startDate),
|
|
endDate: new Date(endDate),
|
|
registrationDeadline: registrationDeadline ? new Date(registrationDeadline) : null,
|
|
goLiveAt: goLiveAt ? new Date(goLiveAt) : undefined,
|
|
price: parseFloat(price),
|
|
picture,
|
|
updatedAt: new Date(),
|
|
createdById: req.user?.id || undefined,
|
|
redirectUrl,
|
|
isHidden: isHidden === true || isHidden === 'true',
|
|
requiresAuth: requiresAuth === false || requiresAuth === 'false' ? false : true,
|
|
};
|
|
|
|
try {
|
|
const event = await prisma.event.create({ data });
|
|
|
|
// Automatically create a main ticket (event option) with the event price
|
|
try {
|
|
if (prisma && prisma.eventOption && typeof prisma.eventOption.create === 'function') {
|
|
await prisma.eventOption.create({
|
|
data: {
|
|
id: uuidv4(),
|
|
eventId: event.id,
|
|
name: 'Main Ticket',
|
|
price: event.price || 0,
|
|
isMainTicket: true,
|
|
}
|
|
});
|
|
}
|
|
} catch (e) {
|
|
// Non-fatal: event is created even if option creation fails
|
|
try { console.warn('[createEvent] Failed to auto-create main ticket option:', e?.message || e); } catch {}
|
|
}
|
|
|
|
// Create form if provided
|
|
try {
|
|
const formInput = req.body?.form;
|
|
if (formInput && prisma && prisma.eventForm) {
|
|
const createdForm = await prisma.eventForm.create({
|
|
data: {
|
|
id: uuidv4(),
|
|
eventId: event.id,
|
|
isRequired: !!formInput.isRequired,
|
|
}
|
|
});
|
|
const fields = Array.isArray(formInput.fields) ? formInput.fields : [];
|
|
for (let i = 0; i < fields.length; i++) {
|
|
const f = fields[i];
|
|
if (!f || !f.type || !f.label) continue;
|
|
await prisma.eventFormField.create({
|
|
data: {
|
|
id: uuidv4(),
|
|
formId: createdForm.id,
|
|
type: f.type,
|
|
label: String(f.label),
|
|
isRequired: !!f.isRequired,
|
|
order: typeof f.order === 'number' ? f.order : i,
|
|
helpText: f.helpText || null,
|
|
options: f.options || undefined,
|
|
}
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
const msg = String(e?.message || e || '');
|
|
try { console.error('[createEvent] Failed to save form/fields:', msg); } catch {}
|
|
return res.status(400).json({ message: 'Failed to save event form/fields', detail: msg, hint: 'Ensure Prisma migrations are applied and Prisma Client is regenerated, then restart the server.' });
|
|
}
|
|
|
|
return res.status(201).json(event);
|
|
} catch (err) {
|
|
// Gracefully handle environments where migration isn't applied yet
|
|
const msg = String(err?.message || '');
|
|
if (msg.includes('Unknown argument `registrationDeadline`')) {
|
|
// @ts-ignore delete field and retry
|
|
delete data.registrationDeadline;
|
|
const event = await prisma.event.create({ data });
|
|
return res.status(201).json(event);
|
|
}
|
|
if (msg.includes('Unknown argument `goLiveAt`')) {
|
|
// @ts-ignore delete field and retry
|
|
delete data.goLiveAt;
|
|
const event = await prisma.event.create({ data });
|
|
return res.status(201).json(event);
|
|
}
|
|
if (msg.includes('Unknown argument `createdById`')) {
|
|
// @ts-ignore delete field and retry
|
|
delete data.createdById;
|
|
const event = await prisma.event.create({ data });
|
|
return res.status(201).json(event);
|
|
}
|
|
if (msg.includes('Unknown argument `redirectUrl`')) {
|
|
// @ts-ignore delete field and retry
|
|
delete data.redirectUrl;
|
|
const event = await prisma.event.create({ data });
|
|
return res.status(201).json(event);
|
|
}
|
|
throw err;
|
|
}
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Get all events
|
|
// @route GET /api/events
|
|
// @access Public
|
|
const getEvents = async (req, res) => {
|
|
try {
|
|
let events;
|
|
// Determine if Prisma Client supports EarlyBirdTier relation (i.e., migration+generate applied)
|
|
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
|
const eventOptionsInclude = canIncludeTiers ? { include: { earlyBirdTiers: true } } : true;
|
|
try {
|
|
events = await prisma.event.findMany({
|
|
where: {
|
|
isActive: true,
|
|
isHidden: false,
|
|
goLiveAt: { lte: new Date() },
|
|
endDate: { gte: new Date() }
|
|
},
|
|
include: { eventOptions: eventOptionsInclude }
|
|
});
|
|
} catch (e) {
|
|
// Fallback if goLiveAt/isHidden not available yet (pre-migration)
|
|
events = await prisma.event.findMany({
|
|
where: {
|
|
isActive: true,
|
|
endDate: { gte: new Date() }
|
|
},
|
|
include: { eventOptions: eventOptionsInclude }
|
|
});
|
|
}
|
|
|
|
// Compute isSoldOut per event: true when every option that has a stock limit
|
|
// is fully sold out. Events with no limited options are never sold out.
|
|
const withStock = await Promise.all(events.map(async ev => {
|
|
try {
|
|
const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0);
|
|
let isSoldOut = false;
|
|
if (limitedOpts.length > 0) {
|
|
const soldCounts = await Promise.all(limitedOpts.map(opt =>
|
|
prisma.registrationOption.aggregate({
|
|
where: { eventOptionId: opt.id, registration: { status: { not: 'cancelled' } } },
|
|
_sum: { quantity: true },
|
|
}).then(r => r._sum?.quantity || 0)
|
|
));
|
|
isSoldOut = limitedOpts.every((opt, i) => soldCounts[i] >= opt.stockLimit);
|
|
}
|
|
return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut };
|
|
} catch {
|
|
return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut: false };
|
|
}
|
|
}));
|
|
|
|
res.json(withStock);
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Get all events (including inactive)
|
|
// @route GET /api/events/all
|
|
// @access Private/Admin
|
|
const getAllEvents = async (req, res) => {
|
|
try {
|
|
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
|
const events = await prisma.event.findMany({
|
|
include: {
|
|
eventOptions: canIncludeTiers ? { include: { earlyBirdTiers: true } } : true
|
|
}
|
|
});
|
|
|
|
const mapped = events.map(ev => ({
|
|
...ev,
|
|
picture: toAbsoluteUrl(req, ev.picture),
|
|
}));
|
|
|
|
res.json(mapped);
|
|
} catch (error) {
|
|
// If prisma client doesn't support the relation include (rare), retry without it
|
|
const events = await prisma.event.findMany({ include: { eventOptions: true } });
|
|
const mapped = events.map(ev => ({ ...ev, picture: toAbsoluteUrl(req, ev.picture) }));
|
|
res.json(mapped);
|
|
}
|
|
};
|
|
|
|
// @desc Get all events for staff/supervisor/admin (always includes hidden; optional past/inactive)
|
|
// @route GET /api/events/all
|
|
// @access Private
|
|
const getEventsAll = async (req, res) => {
|
|
try {
|
|
const includePast = req.query.includePast === 'true';
|
|
const includeInactive = req.query.includeInactive === 'true';
|
|
const excludeClosed = req.query.excludeClosed === 'true';
|
|
|
|
const where = {};
|
|
if (!includeInactive) where.isActive = true;
|
|
if (!includePast) where.endDate = { gte: new Date() };
|
|
if (excludeClosed) where.cashupStatus = { not: 'closed' };
|
|
|
|
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
|
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
|
|
const optionInclude = canIncludeTiers
|
|
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) }
|
|
: undefined;
|
|
const events = await prisma.event.findMany({
|
|
where,
|
|
include: {
|
|
eventOptions: optionInclude ? { include: optionInclude } : true,
|
|
form: { include: { fields: true } }
|
|
},
|
|
orderBy: { startDate: 'asc' }
|
|
});
|
|
|
|
const mapped = events.map(ev => ({
|
|
...ev,
|
|
picture: toAbsoluteUrl(req, ev.picture),
|
|
}));
|
|
|
|
res.json(mapped);
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Get event by ID
|
|
// @route GET /api/events/:id
|
|
// @access Public
|
|
const getEventById = async (req, res) => {
|
|
try {
|
|
const eventId = req.params.id;
|
|
// Determine if Prisma Client supports EarlyBirdTier / OptionVariant
|
|
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
|
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
|
|
const optionInclude = canIncludeTiers
|
|
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) }
|
|
: undefined;
|
|
const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true };
|
|
|
|
// Staff/supervisor/admin need the creator + notify-recipient list to populate the
|
|
// event edit screen's Notifications step; public callers don't need this.
|
|
const isStaffOrHigherForNotify = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role));
|
|
if (isStaffOrHigherForNotify) {
|
|
includeObj.createdBy = { select: { id: true, name: true, email: true } };
|
|
includeObj.notifyRecipients = { select: { id: true, name: true, email: true, role: true } };
|
|
}
|
|
|
|
let event;
|
|
|
|
// Try to include attachments via Prisma if the client has the model
|
|
const canUsePrismaAttachments = (prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findMany === 'function');
|
|
if (canUsePrismaAttachments) {
|
|
try {
|
|
// @ts-ignore - runtime guard ensures this exists
|
|
includeObj.attachments = true;
|
|
event = await prisma.event.findUnique({ where: { id: eventId }, include: includeObj });
|
|
} catch (e) {
|
|
// If relation/table does not exist (migration not deployed), fall back to fetching without attachments
|
|
try {
|
|
event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true, ...(isStaffOrHigherForNotify ? { createdBy: includeObj.createdBy, notifyRecipients: includeObj.notifyRecipients } : {}) } });
|
|
} catch (e2) {
|
|
// Notify-recipients relation not migrated yet either — fall back further
|
|
event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true } });
|
|
}
|
|
// And we will source attachments from the filesystem manifest below
|
|
}
|
|
} else {
|
|
// No model available on the Prisma client; fetch without attachments
|
|
try {
|
|
event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true, ...(isStaffOrHigherForNotify ? { createdBy: includeObj.createdBy, notifyRecipients: includeObj.notifyRecipients } : {}) } });
|
|
} catch (e) {
|
|
event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true } });
|
|
}
|
|
}
|
|
|
|
if (!event) {
|
|
res.status(404);
|
|
throw new Error('Event not found');
|
|
}
|
|
|
|
// Public gating: hide inactive or not-yet-live events from everyone except staff/supervisor/admin,
|
|
// who need to load inactive events for cashup, editing, etc.
|
|
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role));
|
|
if (!isStaffOrHigher) {
|
|
if (event.isActive === false) {
|
|
res.status(404);
|
|
throw new Error('Event not found');
|
|
}
|
|
// goLiveAt may not exist on older schemas — tolerate that read failing without
|
|
// affecting the isActive gate above.
|
|
let goLiveAt = null;
|
|
try { goLiveAt = event.goLiveAt ? new Date(event.goLiveAt) : null; } catch (e) {}
|
|
if (goLiveAt && new Date() < goLiveAt) {
|
|
res.status(404);
|
|
throw new Error('Event not found');
|
|
}
|
|
}
|
|
|
|
// Resolve attachments
|
|
let attachments = [];
|
|
if (event.attachments && Array.isArray(event.attachments)) {
|
|
attachments = event.attachments;
|
|
} else {
|
|
// Fall back to manifest on disk if DB attachments are not available
|
|
try {
|
|
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
|
|
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
|
|
if (fs.existsSync(manifestPath)) {
|
|
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
|
const list = JSON.parse(raw) || [];
|
|
attachments = list;
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
// Try include form definition if available
|
|
let form = null;
|
|
try {
|
|
if (prisma && prisma.eventForm && typeof prisma.eventForm.findUnique === 'function') {
|
|
const f = await prisma.eventForm.findUnique({
|
|
where: { eventId: eventId },
|
|
include: { fields: true },
|
|
});
|
|
if (f) {
|
|
form = {
|
|
id: f.id,
|
|
isRequired: !!f.isRequired,
|
|
fields: (f.fields || []).sort((a,b) => (a.order||0)-(b.order||0)).map(fl => ({
|
|
id: fl.id,
|
|
type: fl.type,
|
|
label: fl.label,
|
|
isRequired: !!fl.isRequired,
|
|
order: fl.order || 0,
|
|
helpText: fl.helpText || null,
|
|
options: fl.options || null,
|
|
}))
|
|
};
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
// Compute availableCount per option and variant
|
|
let eventOptionsWithStock = event.eventOptions || [];
|
|
try {
|
|
if (canIncludeVariants) {
|
|
eventOptionsWithStock = await Promise.all((event.eventOptions || []).map(async opt => {
|
|
// Total sold quantity for this option (all non-cancelled)
|
|
const soldAgg = await prisma.registrationOption.aggregate({
|
|
where: { eventOptionId: opt.id, registration: { status: { not: 'cancelled' } } },
|
|
_sum: { quantity: true }
|
|
});
|
|
const soldCount = soldAgg._sum?.quantity || 0;
|
|
const availableCount = opt.stockLimit > 0 ? Math.max(0, opt.stockLimit - soldCount) : null;
|
|
|
|
// Per-variant stock
|
|
const variantsWithStock = await Promise.all((opt.variants || []).map(async v => {
|
|
if (!v.stockLimit) return { ...v, soldCount: 0, availableCount: null };
|
|
const vAgg = await prisma.registrationOption.aggregate({
|
|
where: { variantId: v.id, registration: { status: { not: 'cancelled' } } },
|
|
_sum: { quantity: true }
|
|
});
|
|
const vSold = vAgg._sum?.quantity || 0;
|
|
return { ...v, soldCount: vSold, availableCount: Math.max(0, v.stockLimit - vSold) };
|
|
}));
|
|
|
|
return { ...opt, soldCount, availableCount, variants: variantsWithStock };
|
|
}));
|
|
}
|
|
} catch (e) {
|
|
// Non-fatal: fall back to options without stock info
|
|
}
|
|
|
|
const mapped = {
|
|
...event,
|
|
eventOptions: eventOptionsWithStock,
|
|
picture: toAbsoluteUrl(req, event.picture),
|
|
attachments: (attachments || []).map(att => ({
|
|
...att,
|
|
url: toAbsoluteUrl(req, att.url)
|
|
})),
|
|
form,
|
|
};
|
|
res.json(mapped);
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Update event
|
|
// @route PUT /api/events/:id
|
|
// @access Private/Admin
|
|
const updateEvent = async (req, res) => {
|
|
try {
|
|
const event = await prisma.event.findUnique({
|
|
where: { id: req.params.id }
|
|
});
|
|
|
|
if (!event) {
|
|
res.status(404);
|
|
throw new Error('Event not found');
|
|
}
|
|
|
|
// A closed (cashed-up) event's pricing/structure must not change under the reconciled
|
|
// totals — same rule already enforced for payments/costs. Admin can reopen first.
|
|
await assertEventOpen(req.params.id, res);
|
|
|
|
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth } = req.body;
|
|
|
|
const data = {
|
|
title: title || event.title,
|
|
description: description !== undefined ? description : event.description,
|
|
startDate: startDate ? new Date(startDate) : event.startDate,
|
|
endDate: endDate ? new Date(endDate) : event.endDate,
|
|
registrationDeadline: registrationDeadline !== undefined ? (registrationDeadline ? new Date(registrationDeadline) : null) : event.registrationDeadline,
|
|
goLiveAt: goLiveAt !== undefined ? (goLiveAt ? new Date(goLiveAt) : new Date()) : (event.goLiveAt || undefined),
|
|
price: price ? parseFloat(price) : event.price,
|
|
picture: picture !== undefined ? picture : event.picture,
|
|
isActive: isActive !== undefined ? isActive : event.isActive,
|
|
isHidden: isHidden !== undefined ? (isHidden === true || isHidden === 'true') : (event.isHidden ?? false),
|
|
requiresAuth: requiresAuth !== undefined ? !(requiresAuth === false || requiresAuth === 'false') : (event.requiresAuth ?? true),
|
|
updatedAt: new Date(),
|
|
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
|
|
};
|
|
|
|
try {
|
|
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
|
|
|
|
|
|
// Upsert form if provided
|
|
try {
|
|
const formInput = req.body?.form;
|
|
if (formInput && prisma && prisma.eventForm) {
|
|
// find existing form
|
|
let existing = null;
|
|
try { existing = await prisma.eventForm.findUnique({ where: { eventId: updatedEvent.id } }); } catch {}
|
|
if (!existing) {
|
|
existing = await prisma.eventForm.create({ data: { id: uuidv4(), eventId: updatedEvent.id, isRequired: !!formInput.isRequired } });
|
|
} else {
|
|
await prisma.eventForm.update({ where: { id: existing.id }, data: { isRequired: !!formInput.isRequired } });
|
|
}
|
|
const formId = existing.id;
|
|
// Replace fields: simple approach — delete all and recreate
|
|
try { await prisma.eventFormField.deleteMany({ where: { formId } }); } catch {}
|
|
const fields = Array.isArray(formInput.fields) ? formInput.fields : [];
|
|
for (let i = 0; i < fields.length; i++) {
|
|
const f = fields[i];
|
|
if (!f || !f.type || !f.label) continue;
|
|
await prisma.eventFormField.create({
|
|
data: {
|
|
id: uuidv4(),
|
|
formId,
|
|
type: f.type,
|
|
label: String(f.label),
|
|
isRequired: !!f.isRequired,
|
|
order: typeof f.order === 'number' ? f.order : i,
|
|
helpText: f.helpText || null,
|
|
options: f.options || undefined,
|
|
}
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
const msg = String(e?.message || e || '');
|
|
try { console.error('[updateEvent] Failed to save form/fields:', msg); } catch {}
|
|
return res.status(400).json({ message: 'Failed to save event form/fields', detail: msg, hint: 'Ensure Prisma migrations are applied and Prisma Client is regenerated, then restart the server.' });
|
|
}
|
|
|
|
return res.json(updatedEvent);
|
|
} catch (err) {
|
|
const msg = String(err?.message || '');
|
|
if (msg.includes('Unknown argument `registrationDeadline`')) {
|
|
// @ts-ignore
|
|
delete data.registrationDeadline;
|
|
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
|
|
return res.json(updatedEvent);
|
|
}
|
|
if (msg.includes('Unknown argument `goLiveAt`')) {
|
|
// @ts-ignore
|
|
delete data.goLiveAt;
|
|
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
|
|
return res.json(updatedEvent);
|
|
}
|
|
throw err;
|
|
}
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Get the list of users who receive registration/payment/daily-summary
|
|
// notifications for this event. Lightweight — selects only id/name/email/role,
|
|
// skipping the option/stock computation that GET /api/events/:id does, so the
|
|
// Notifications step in the event editor loads instantly.
|
|
// @route GET /api/events/:id/notify-recipients
|
|
// @access Private/Supervisor
|
|
const getEventNotifyRecipients = async (req, res) => {
|
|
try {
|
|
const event = await prisma.event.findUnique({
|
|
where: { id: req.params.id },
|
|
select: { notifyRecipients: { select: { id: true, name: true, email: true, role: true } } },
|
|
});
|
|
if (!event) {
|
|
res.status(404);
|
|
throw new Error('Event not found');
|
|
}
|
|
res.json(event.notifyRecipients);
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Set the list of users who receive registration/payment/daily-summary
|
|
// notifications for this event. Empty list falls back to the event creator.
|
|
// @route PUT /api/events/:id/notify-recipients
|
|
// @access Private/Supervisor
|
|
const updateEventNotifyRecipients = async (req, res) => {
|
|
try {
|
|
const { userIds } = req.body;
|
|
if (!Array.isArray(userIds)) {
|
|
res.status(400);
|
|
throw new Error('userIds must be an array');
|
|
}
|
|
|
|
const event = await prisma.event.findUnique({ where: { id: req.params.id } });
|
|
if (!event) {
|
|
res.status(404);
|
|
throw new Error('Event not found');
|
|
}
|
|
|
|
const updated = await prisma.event.update({
|
|
where: { id: req.params.id },
|
|
data: { notifyRecipients: { set: userIds.map(id => ({ id })) } },
|
|
include: { notifyRecipients: { select: { id: true, name: true, email: true, role: true } } },
|
|
});
|
|
|
|
res.json(updated.notifyRecipients);
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Delete event (set inactive)
|
|
// @route DELETE /api/events/:id
|
|
// @access Private/Admin
|
|
const deleteEvent = async (req, res) => {
|
|
try {
|
|
const event = await prisma.event.findUnique({
|
|
where: { id: req.params.id }
|
|
});
|
|
|
|
if (!event) {
|
|
res.status(404);
|
|
throw new Error('Event not found');
|
|
}
|
|
|
|
// Instead of deleting, we set isActive to false
|
|
await prisma.event.update({
|
|
where: { id: req.params.id },
|
|
data: {
|
|
isActive: false,
|
|
updatedAt: new Date()
|
|
}
|
|
});
|
|
|
|
res.json({ message: 'Event deactivated' });
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Create event option
|
|
// @route POST /api/events/:id/options
|
|
// @access Private/Admin
|
|
const createEventOption = async (req, res) => {
|
|
try {
|
|
const event = await prisma.event.findUnique({
|
|
where: { id: req.params.id }
|
|
});
|
|
|
|
if (!event) {
|
|
res.status(404);
|
|
throw new Error('Event not found');
|
|
}
|
|
|
|
await assertEventOpen(req.params.id, res);
|
|
|
|
const { name, price, isMainTicket, stockLimit } = req.body;
|
|
|
|
const eventOption = await prisma.eventOption.create({
|
|
data: {
|
|
id: uuidv4(),
|
|
eventId: req.params.id,
|
|
name,
|
|
price: parseFloat(price),
|
|
isMainTicket: isMainTicket || false,
|
|
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
|
|
}
|
|
});
|
|
|
|
res.status(201).json(eventOption);
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Update event option
|
|
// @route PUT /api/events/options/:id
|
|
// @access Private/Admin
|
|
const updateEventOption = async (req, res) => {
|
|
try {
|
|
const eventOption = await prisma.eventOption.findUnique({
|
|
where: { id: req.params.id },
|
|
include: { earlyBirdTiers: true }
|
|
});
|
|
|
|
if (!eventOption) {
|
|
res.status(404);
|
|
throw new Error('Event option not found');
|
|
}
|
|
|
|
await assertEventOpen(eventOption.eventId, res);
|
|
|
|
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants } = req.body;
|
|
|
|
const updatedEventOption = await prisma.eventOption.update({
|
|
where: { id: req.params.id },
|
|
data: {
|
|
name: name !== undefined ? name : eventOption.name,
|
|
price: price !== undefined ? parseFloat(price) : eventOption.price,
|
|
isMainTicket: isMainTicket !== undefined ? isMainTicket : eventOption.isMainTicket,
|
|
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : eventOption.stockLimit,
|
|
}
|
|
});
|
|
|
|
// If earlyBirdTiers provided, replace all tiers for this option (including per-variant ones)
|
|
if (Array.isArray(earlyBirdTiers)) {
|
|
try { await prisma.earlyBirdTier.deleteMany({ where: { eventOptionId: updatedEventOption.id } }); } catch {}
|
|
for (let i = 0; i < earlyBirdTiers.length; i++) {
|
|
const t = earlyBirdTiers[i];
|
|
if (!t || !t.deadline || (t.price === undefined || t.price === null)) continue;
|
|
const deadline = new Date(t.deadline);
|
|
const p = parseFloat(t.price);
|
|
if (!(deadline instanceof Date) || isNaN(deadline.getTime()) || !(p >= 0)) continue;
|
|
await prisma.earlyBirdTier.create({
|
|
data: {
|
|
id: require('uuid').v4(),
|
|
eventOptionId: updatedEventOption.id,
|
|
variantId: t.variantId || null,
|
|
deadline,
|
|
price: p,
|
|
order: typeof t.order === 'number' ? t.order : i,
|
|
stockLimit: t.stockLimit ? parseInt(t.stockLimit, 10) : 0,
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// If variants array provided, upsert variants
|
|
if (Array.isArray(variants)) {
|
|
const incomingIds = variants.filter(v => v.id).map(v => v.id);
|
|
// Delete variants not in the new list (only if they have no registrations)
|
|
const existingVariants = await prisma.optionVariant.findMany({ where: { eventOptionId: updatedEventOption.id } });
|
|
for (const ev of existingVariants) {
|
|
if (!incomingIds.includes(ev.id)) {
|
|
const usageCount = await prisma.registrationOption.count({ where: { variantId: ev.id } });
|
|
if (usageCount === 0) {
|
|
await prisma.optionVariant.delete({ where: { id: ev.id } });
|
|
}
|
|
}
|
|
}
|
|
// Upsert incoming variants
|
|
for (let i = 0; i < variants.length; i++) {
|
|
const v = variants[i];
|
|
if (!v.name) continue;
|
|
const variantPrice = v.price !== undefined && v.price !== null && v.price !== '' ? parseFloat(v.price) : null;
|
|
const variantStockLimit = v.stockLimit !== undefined ? parseInt(v.stockLimit, 10) : 0;
|
|
if (v.id) {
|
|
await prisma.optionVariant.update({
|
|
where: { id: v.id },
|
|
data: { name: v.name, price: variantPrice, stockLimit: variantStockLimit, order: i }
|
|
});
|
|
} else {
|
|
await prisma.optionVariant.create({
|
|
data: {
|
|
id: require('uuid').v4(),
|
|
eventOptionId: updatedEventOption.id,
|
|
name: v.name,
|
|
price: variantPrice,
|
|
stockLimit: variantStockLimit,
|
|
order: i,
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const refreshed = await prisma.eventOption.findUnique({
|
|
where: { id: updatedEventOption.id },
|
|
include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } }
|
|
});
|
|
|
|
res.json(refreshed);
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Delete event option
|
|
// @route DELETE /api/events/options/:id
|
|
// @access Private/Admin
|
|
const deleteEventOption = async (req, res) => {
|
|
try {
|
|
const eventOption = await prisma.eventOption.findUnique({
|
|
where: { id: req.params.id }
|
|
});
|
|
|
|
if (!eventOption) {
|
|
res.status(404);
|
|
throw new Error('Event option not found');
|
|
}
|
|
|
|
await assertEventOpen(eventOption.eventId, res);
|
|
|
|
// If deleting a main ticket, ensure at least one other main ticket exists for the same event
|
|
if (eventOption.isMainTicket) {
|
|
const otherMainCount = await prisma.eventOption.count({
|
|
where: {
|
|
eventId: eventOption.eventId,
|
|
isMainTicket: true,
|
|
NOT: { id: eventOption.id },
|
|
}
|
|
});
|
|
if (otherMainCount === 0) {
|
|
res.status(400);
|
|
throw new Error('Cannot delete the only main ticket option');
|
|
}
|
|
}
|
|
|
|
// Check if there are any registrations using this option
|
|
const registrationOptions = await prisma.registrationOption.findMany({
|
|
where: { eventOptionId: req.params.id }
|
|
});
|
|
|
|
if (registrationOptions.length > 0) {
|
|
res.status(400);
|
|
throw new Error('Cannot delete event option that has registrations');
|
|
}
|
|
|
|
await prisma.eventOption.delete({
|
|
where: { id: req.params.id }
|
|
});
|
|
|
|
res.json({ message: 'Event option removed' });
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Create a variant for an event option
|
|
// @route POST /api/events/options/:id/variants
|
|
// @access Private/Supervisor
|
|
const createOptionVariant = async (req, res) => {
|
|
try {
|
|
const eventOption = await prisma.eventOption.findUnique({ where: { id: req.params.id } });
|
|
if (!eventOption) { res.status(404); throw new Error('Event option not found'); }
|
|
await assertEventOpen(eventOption.eventId, res);
|
|
const { name, price, stockLimit, order } = req.body;
|
|
if (!name) { res.status(400); throw new Error('Variant name is required'); }
|
|
const variant = await prisma.optionVariant.create({
|
|
data: {
|
|
id: uuidv4(),
|
|
eventOptionId: req.params.id,
|
|
name,
|
|
price: price !== undefined && price !== null && price !== '' ? parseFloat(price) : null,
|
|
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
|
|
order: order !== undefined ? parseInt(order, 10) : 0,
|
|
}
|
|
});
|
|
res.status(201).json(variant);
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Update a variant
|
|
// @route PUT /api/events/variants/:id
|
|
// @access Private/Supervisor
|
|
const updateOptionVariant = async (req, res) => {
|
|
try {
|
|
const variant = await prisma.optionVariant.findUnique({ where: { id: req.params.id }, include: { eventOption: { select: { eventId: true } } } });
|
|
if (!variant) { res.status(404); throw new Error('Variant not found'); }
|
|
await assertEventOpen(variant.eventOption.eventId, res);
|
|
const { name, price, stockLimit, order } = req.body;
|
|
const updated = await prisma.optionVariant.update({
|
|
where: { id: req.params.id },
|
|
data: {
|
|
name: name !== undefined ? name : variant.name,
|
|
price: price !== undefined ? (price === null || price === '' ? null : parseFloat(price)) : variant.price,
|
|
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : variant.stockLimit,
|
|
order: order !== undefined ? parseInt(order, 10) : variant.order,
|
|
}
|
|
});
|
|
res.json(updated);
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Delete a variant
|
|
// @route DELETE /api/events/variants/:id
|
|
// @access Private/Admin
|
|
const deleteOptionVariant = async (req, res) => {
|
|
try {
|
|
const variant = await prisma.optionVariant.findUnique({ where: { id: req.params.id }, include: { eventOption: { select: { eventId: true } } } });
|
|
if (!variant) { res.status(404); throw new Error('Variant not found'); }
|
|
await assertEventOpen(variant.eventOption.eventId, res);
|
|
const usageCount = await prisma.registrationOption.count({ where: { variantId: req.params.id } });
|
|
if (usageCount > 0) { res.status(400); throw new Error('Cannot delete variant with existing registrations'); }
|
|
await prisma.optionVariant.delete({ where: { id: req.params.id } });
|
|
res.json({ message: 'Variant deleted' });
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// File upload setup for attachments (PDFs/docs)
|
|
const attachmentsStorage = multer.diskStorage({
|
|
destination: function (req, file, cb) {
|
|
const uploadPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
|
|
try {
|
|
if (!fs.existsSync(uploadPath)) fs.mkdirSync(uploadPath, { recursive: true });
|
|
cb(null, uploadPath);
|
|
} catch (err) {
|
|
cb(err);
|
|
}
|
|
},
|
|
filename: function (req, file, cb) {
|
|
const unique = `${Date.now()}-${file.originalname}`;
|
|
cb(null, unique);
|
|
}
|
|
});
|
|
|
|
const allowedDocs = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.csv', '.zip'];
|
|
const uploadAttachmentMulter = multer({
|
|
storage: attachmentsStorage,
|
|
limits: { fileSize: 20 * 1024 * 1024 }, // 20MB
|
|
fileFilter: (req, file, cb) => {
|
|
const ext = path.extname(file.originalname).toLowerCase();
|
|
if (!allowedDocs.includes(ext)) {
|
|
return cb(new Error('Unsupported file type'));
|
|
}
|
|
cb(null, true);
|
|
}
|
|
});
|
|
|
|
// @desc List attachments for an event
|
|
// @route GET /api/events/:id/attachments
|
|
// @access Private/Supervisor (for dashboard) and could be public via event details
|
|
const listEventAttachments = async (req, res) => {
|
|
try {
|
|
const eventId = req.params.id;
|
|
// If attachments model isn't available yet (pre-migration/generate), fall back to filesystem manifest
|
|
if (!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findMany === 'function')) {
|
|
try {
|
|
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
|
|
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
|
|
if (!fs.existsSync(manifestPath)) return res.json([]);
|
|
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
|
const list = JSON.parse(raw);
|
|
return res.json(list.map(att => ({ ...att, url: toAbsoluteUrl(req, att.url) })));
|
|
} catch (e) {
|
|
// If manifest is corrupt or unreadable, return empty list rather than failing
|
|
return res.json([]);
|
|
}
|
|
}
|
|
const items = await prisma.eventAttachment.findMany({ where: { eventId }, orderBy: { createdAt: 'desc' } });
|
|
res.json(items.map(att => ({ ...att, url: toAbsoluteUrl(req, att.url) })));
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Upload attachment for an event
|
|
// @route POST /api/events/:id/attachments
|
|
// @access Private/Supervisor
|
|
const uploadEventAttachment = [
|
|
(req, res, next) => uploadAttachmentMulter.single('file')(req, res, (err) => {
|
|
if (err) req.multerError = err;
|
|
next();
|
|
}),
|
|
async (req, res) => {
|
|
try {
|
|
if (req.multerError) throw req.multerError;
|
|
if (!req.file) return res.status(400).json({ message: 'No file uploaded' });
|
|
const eventId = req.params.id;
|
|
const relUrl = `/uploads/event-files/${req.file.filename}`;
|
|
|
|
// Fallback: if attachments model is not available yet, store in filesystem manifest
|
|
if (!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.create === 'function')) {
|
|
try { console.warn('[attachments] Falling back to filesystem manifest: Prisma client lacks EventAttachment model. Ensure prisma generate ran in the deployed app dir and server restarted.'); } catch {}
|
|
try {
|
|
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
|
|
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
|
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
|
|
const entry = {
|
|
id: uuidv4(),
|
|
eventId,
|
|
originalName: req.file.originalname,
|
|
filename: req.file.filename,
|
|
mimeType: req.file.mimetype,
|
|
size: req.file.size,
|
|
url: relUrl,
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
let list = [];
|
|
if (fs.existsSync(manifestPath)) {
|
|
try {
|
|
list = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) || [];
|
|
} catch {}
|
|
}
|
|
list.unshift(entry);
|
|
fs.writeFileSync(manifestPath, JSON.stringify(list, null, 2), 'utf-8');
|
|
return res.status(201).json({ ...entry, url: toAbsoluteUrl(req, entry.url) });
|
|
} catch (e) {
|
|
return res.status(500).json({ message: 'Failed to record attachment', detail: e?.message });
|
|
}
|
|
}
|
|
|
|
// Normal path: DB-backed
|
|
try {
|
|
const created = await prisma.eventAttachment.create({
|
|
data: {
|
|
id: uuidv4(),
|
|
eventId,
|
|
originalName: req.file.originalname,
|
|
filename: req.file.filename,
|
|
mimeType: req.file.mimetype,
|
|
size: req.file.size,
|
|
url: relUrl,
|
|
}
|
|
});
|
|
return res.status(201).json({ ...created, url: toAbsoluteUrl(req, created.url) });
|
|
} catch (e) {
|
|
const msg = String(e?.message || e || '');
|
|
const isMissingTable = msg.includes('does not exist') || msg.includes('relation') || msg.includes('P2021');
|
|
if (isMissingTable) {
|
|
try { console.warn('[attachments] DB create failed, falling back to filesystem manifest. Error:', msg); } catch {}
|
|
try {
|
|
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
|
|
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
|
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
|
|
const entry = {
|
|
id: uuidv4(),
|
|
eventId,
|
|
originalName: req.file.originalname,
|
|
filename: req.file.filename,
|
|
mimeType: req.file.mimetype,
|
|
size: req.file.size,
|
|
url: relUrl,
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
let list = [];
|
|
if (fs.existsSync(manifestPath)) {
|
|
try { list = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) || []; } catch {}
|
|
}
|
|
list.unshift(entry);
|
|
fs.writeFileSync(manifestPath, JSON.stringify(list, null, 2), 'utf-8');
|
|
return res.status(201).json({ ...entry, url: toAbsoluteUrl(req, entry.url) });
|
|
} catch (fallbackErr) {
|
|
return res.status(500).json({ message: 'Failed to record attachment', detail: String(fallbackErr?.message || fallbackErr) });
|
|
}
|
|
}
|
|
// Unknown error
|
|
throw e;
|
|
}
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
}
|
|
];
|
|
|
|
// @desc Delete attachment
|
|
// @route DELETE /api/events/:eventId/attachments/:attachmentId
|
|
// @access Private/Admin or Supervisor
|
|
const deleteEventAttachment = async (req, res) => {
|
|
try {
|
|
const { eventId, attachmentId } = req.params;
|
|
// If attachments model is not available yet, fall back to filesystem manifest
|
|
if (!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findUnique === 'function')) {
|
|
try {
|
|
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
|
|
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
|
|
if (!fs.existsSync(manifestPath)) return res.status(404).json({ message: 'Attachment not found' });
|
|
const list = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) || [];
|
|
const idx = list.findIndex((x) => x.id === attachmentId);
|
|
if (idx === -1) return res.status(404).json({ message: 'Attachment not found' });
|
|
const att = list[idx];
|
|
// Remove file on disk (best-effort)
|
|
try {
|
|
const filePath = path.join(uploadDir, att.filename);
|
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
} catch {}
|
|
// Remove from manifest
|
|
list.splice(idx, 1);
|
|
fs.writeFileSync(manifestPath, JSON.stringify(list, null, 2), 'utf-8');
|
|
return res.json({ message: 'Attachment deleted' });
|
|
} catch (e) {
|
|
return res.status(500).json({ message: 'Failed to delete attachment', detail: e?.message });
|
|
}
|
|
}
|
|
const att = await prisma.eventAttachment.findUnique({ where: { id: attachmentId } });
|
|
if (!att || att.eventId !== eventId) {
|
|
return res.status(404).json({ message: 'Attachment not found' });
|
|
}
|
|
// Remove file on disk (best-effort)
|
|
try {
|
|
const filePath = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files', att.filename);
|
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
} catch {}
|
|
await prisma.eventAttachment.delete({ where: { id: attachmentId } });
|
|
res.json({ message: 'Attachment deleted' });
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Diagnostics for attachments pipeline
|
|
// @route GET /api/events/attachments/status
|
|
// @access Private/Admin
|
|
const attachmentsStatus = async (req, res) => {
|
|
try {
|
|
const hasModel = !!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findMany === 'function');
|
|
let tableQueryable = false;
|
|
let probeError = null;
|
|
if (hasModel) {
|
|
try {
|
|
await prisma.eventAttachment.findFirst({});
|
|
tableQueryable = true;
|
|
} catch (e) {
|
|
probeError = String(e?.message || e);
|
|
}
|
|
}
|
|
// Mask DB URL to avoid leaking secrets
|
|
const dbUrl = process.env.DATABASE_URL || '';
|
|
let dbInfo = null;
|
|
try {
|
|
const u = new URL(dbUrl);
|
|
dbInfo = {
|
|
protocol: u.protocol.replace(':',''),
|
|
host: u.hostname,
|
|
port: u.port || undefined,
|
|
database: (u.pathname || '').replace(/^\//,'') || undefined,
|
|
};
|
|
} catch {}
|
|
res.json({
|
|
prismaClientHasEventAttachmentModel: hasModel,
|
|
databaseTableQueryable: tableQueryable,
|
|
probeError,
|
|
database: dbInfo,
|
|
});
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Admin-triggered sync: import manifest files into DB
|
|
// @route POST /api/events/attachments/sync
|
|
// @access Private/Admin
|
|
const { syncManifestsToDb } = require('../utils/attachmentsSync');
|
|
const attachmentsSync = async (req, res) => {
|
|
try {
|
|
const dryRun = String(req.query.dryRun || '').toLowerCase() === 'true';
|
|
const remove = String(req.query.remove || '').toLowerCase() === 'true';
|
|
const summary = await syncManifestsToDb(prisma, { dryRun, removeManifestAfterImport: remove });
|
|
res.json({ dryRun, remove, ...summary });
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Email attendees for an event with filters and preview support
|
|
// @route POST /api/events/:id/email-attendees
|
|
// @access Private/Supervisor or Admin
|
|
const emailEventAttendees = async (req, res) => {
|
|
try {
|
|
const eventId = req.params.id;
|
|
const { subject, html, text, filter, dryRun, template } = req.body || {};
|
|
|
|
// For non-ticket templates or custom messages, require subject and content
|
|
if ((template !== 'tickets') && (!subject || !(html || text))) {
|
|
res.status(400);
|
|
throw new Error('Subject and message (html or text) are required unless template is "tickets"');
|
|
}
|
|
|
|
// Ensure event exists
|
|
const event = await prisma.event.findUnique({ where: { id: eventId } });
|
|
if (!event) { res.status(404); throw new Error('Event not found'); }
|
|
|
|
// Optional promo event (used by Automations: Next Event Promo)
|
|
let promoEvent = null;
|
|
try {
|
|
const promoEventId = req.body?.promoEventId;
|
|
if (promoEventId && typeof promoEventId === 'string') {
|
|
promoEvent = await prisma.event.findUnique({ where: { id: promoEventId } });
|
|
}
|
|
} catch {}
|
|
|
|
// Build where clause for registrations
|
|
const where = { eventId };
|
|
|
|
// Filter by registration status groups: paid, unpaid, partial_paid, cancelled, any
|
|
const statusFilter = (filter && filter.status) ? String(filter.status) : 'any';
|
|
if (statusFilter === 'paid') {
|
|
where.status = 'paid';
|
|
} else if (statusFilter === 'unpaid') {
|
|
where.status = { in: ['pending', 'partial_paid'] };
|
|
} else if (statusFilter === 'partial_paid') {
|
|
where.status = 'partial_paid';
|
|
} else if (statusFilter === 'cancelled') {
|
|
where.status = 'cancelled';
|
|
}
|
|
|
|
// Optional explicit attendee selection by userIds
|
|
const attendeeIds = Array.isArray(filter?.attendeeIds) ? filter.attendeeIds.filter((x) => typeof x === 'string' && x.trim().length > 0) : [];
|
|
if (attendeeIds.length > 0) {
|
|
// Prefer server-side filtering when possible
|
|
where.userId = { in: attendeeIds };
|
|
}
|
|
|
|
// Name/email substring filter (kept for backward compatibility)
|
|
const nameQ = (filter && filter.name) ? String(filter.name).trim() : '';
|
|
const emailQ = (filter && filter.email) ? String(filter.email).trim() : '';
|
|
|
|
const registrations = await prisma.registration.findMany({
|
|
where,
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true, isActive: true } },
|
|
// Include options with eventOption and early-bird tiers so pricing/balances compute correctly
|
|
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
|
payments: true,
|
|
},
|
|
orderBy: { createdAt: 'asc' }
|
|
});
|
|
|
|
// Apply name/email filter in JS (Prisma cross-relational OR contains with guards varies across versions)
|
|
// Also exclude guest/system-generated emails and suspended users
|
|
let filtered = registrations.filter(r => {
|
|
if (!r.user || !r.user.email) return false;
|
|
if (r.user.email.endsWith('@guest.local')) return false;
|
|
if (r.user.isActive === false) return false;
|
|
return true;
|
|
});
|
|
if (nameQ) {
|
|
const q = nameQ.toLowerCase();
|
|
filtered = filtered.filter(r => (r.user?.name || '').toLowerCase().includes(q));
|
|
}
|
|
if (emailQ) {
|
|
const q = emailQ.toLowerCase();
|
|
filtered = filtered.filter(r => (r.user?.email || '').toLowerCase().includes(q));
|
|
}
|
|
|
|
// Unique recipients by email to avoid duplicates across multiple registrations
|
|
const uniqMap = new Map();
|
|
for (const r of filtered) {
|
|
const email = (r.user?.email || '').trim();
|
|
if (!email) continue;
|
|
if (!uniqMap.has(email)) {
|
|
uniqMap.set(email, { email, name: r.user?.name || '' });
|
|
}
|
|
}
|
|
const recipients = Array.from(uniqMap.values());
|
|
|
|
if (dryRun) {
|
|
return res.json({ eventId, matched: recipients.length, recipients: recipients.slice(0, 20) });
|
|
}
|
|
|
|
const { sendMail } = require('../utils/email');
|
|
const { computeRegistrationTotalDue } = require('../utils/pricing');
|
|
const { replacePlaceholders } = require('../utils/placeholders');
|
|
|
|
function fmtAmount(amt) {
|
|
const n = Number(amt || 0);
|
|
return `R${n.toFixed(2)}`;
|
|
}
|
|
function fmtDate(d) {
|
|
try { return new Date(d).toLocaleString(); } catch { return String(d); }
|
|
}
|
|
|
|
// Build per-recipient registration aggregates for this event
|
|
const regsByEmail = new Map();
|
|
for (const r of registrations) {
|
|
const em = (r.user?.email || '').trim();
|
|
if (!em) continue;
|
|
if (!regsByEmail.has(em)) regsByEmail.set(em, []);
|
|
regsByEmail.get(em).push(r);
|
|
}
|
|
|
|
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
|
|
|
// Generates a live Yoco checkout link for the recipient's first registration with an
|
|
// outstanding balance — only called when the template actually uses {{payment.link}}, to
|
|
// avoid an unnecessary Yoco API call per recipient otherwise.
|
|
function makePaymentLinkResolver(regs) {
|
|
return async () => {
|
|
const reg = (regs || []).find(r => {
|
|
const due = computeRegistrationTotalDue(r, new Date());
|
|
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
|
return due - paid > 0.01;
|
|
});
|
|
if (!reg) return '';
|
|
const { createRegistrationCheckoutInternal } = require('./paymentController');
|
|
const result = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
|
|
successUrl: `${baseUrl}/payment/success`,
|
|
cancelUrl: `${baseUrl}/payment/cancel`,
|
|
failureUrl: `${baseUrl}/payment/failure`,
|
|
});
|
|
return result.redirectUrl;
|
|
};
|
|
}
|
|
|
|
// Fire-and-forget: respond immediately with a queued count, then send in the background.
|
|
// Large recipient lists used to block the request until every email was sent — now the
|
|
// caller gets an instant response and failures are just logged server-side.
|
|
if (template === 'tickets') {
|
|
const { emailTickets } = require('./ticketController');
|
|
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: 'tickets' });
|
|
(async () => {
|
|
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
|
const regs = regsByEmail.get(rcpt.email) || [];
|
|
for (const reg of regs) {
|
|
const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } };
|
|
const mockRes = { status: () => mockRes, json: () => {} };
|
|
await emailTickets(mockReq, mockRes);
|
|
}
|
|
}));
|
|
results.forEach((r, i) => {
|
|
if (r.status === 'rejected') {
|
|
try { console.warn('[email-attendees tickets] Failed for', recipients[i]?.email, r.reason?.message || r.reason); } catch {}
|
|
}
|
|
});
|
|
})();
|
|
return;
|
|
}
|
|
|
|
const eventTitle = event?.title || 'the event';
|
|
const eventStart = event?.startDate ? fmtDate(event.startDate) : '';
|
|
// Build event and promo links for placeholders
|
|
const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`;
|
|
const eventLinkHtml = `<a href="${eventLink}">${eventLink}</a>`;
|
|
const promoTitle = promoEvent?.title || '';
|
|
const promoLink = promoEvent ? `${baseUrl}/events/${encodeURIComponent(promoEvent.id)}` : '';
|
|
const promoLinkHtml = promoLink ? `<a href="${promoLink}">${promoLink}</a>` : '';
|
|
|
|
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: template || 'custom' });
|
|
|
|
(async () => {
|
|
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
|
const regs = regsByEmail.get(rcpt.email) || [];
|
|
// Sum outstanding balance across this user's registrations for the event
|
|
let totalDue = 0; let totalPaid = 0;
|
|
for (const r of regs) {
|
|
const due = computeRegistrationTotalDue(r, new Date());
|
|
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
|
totalDue += due; totalPaid += paid;
|
|
}
|
|
const balance = Math.max(totalDue - totalPaid, 0);
|
|
const paymentLinkResolver = makePaymentLinkResolver(regs);
|
|
// Build context for placeholder replacement
|
|
const ctxBase = {
|
|
name: rcpt.name || '',
|
|
eventTitle,
|
|
eventStart,
|
|
eventLink,
|
|
promoTitle,
|
|
promoLink,
|
|
balance,
|
|
balanceFmt: fmtAmount(balance),
|
|
paymentLinkResolver,
|
|
};
|
|
const ctxHtml = {
|
|
...ctxBase,
|
|
eventLinkHtml,
|
|
promoLinkHtml,
|
|
};
|
|
|
|
// If a known template is selected and no explicit content provided, auto-build content
|
|
let subj = subject;
|
|
let h = html; let t = text;
|
|
if (!h && !t && template) {
|
|
if (template === 'payment_reminder') {
|
|
subj = `Payment reminder: ${eventTitle}`;
|
|
t = `Hi {{name}}\n\nThis is a friendly reminder that you have an outstanding balance of {{balance}} for {{event.title}}.\nEvent starts: {{event.start}}\n\nPlease settle your balance to secure your tickets. Thank you!`;
|
|
h = `
|
|
<div style="font-family:Arial,sans-serif;line-height:1.6;color:#333">
|
|
<h2>Payment reminder</h2>
|
|
<p>Hi {{name}},</p>
|
|
<p>You have an outstanding balance of <strong>{{balance}}</strong> for <strong>{{event.title}}</strong>.</p>
|
|
${eventStart ? `<p><strong>Event starts:</strong> {{event.start}}</p>` : ''}
|
|
<p>Please settle your balance to secure your tickets. Thank you!</p>
|
|
</div>`;
|
|
} else if (template === 'event_reminder') {
|
|
subj = `Reminder: ${eventTitle} on ${eventStart || ''}`.trim();
|
|
t = `Hi {{name}}\n\nA quick reminder about {{event.title}}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`;
|
|
h = `
|
|
<div style="font-family:Arial,sans-serif;line-height:1.6;color:#333">
|
|
<h2>Event reminder</h2>
|
|
<p>Hi {{name}},</p>
|
|
<p>This is a reminder for <strong>{{event.title}}</strong>.</p>
|
|
${eventStart ? `<p><strong>Start:</strong> {{event.start}}</p>` : ''}
|
|
<p>We look forward to seeing you!</p>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
// Always perform placeholder replacement on whatever we have
|
|
const finalSubject = await replacePlaceholders(subj || '', ctxBase);
|
|
const finalHtml = h ? await replacePlaceholders(h, ctxHtml) : undefined;
|
|
const finalText = (!h ? await replacePlaceholders(t || '', ctxBase) : undefined);
|
|
|
|
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
|
|
}));
|
|
results.forEach((r, i) => {
|
|
if (r.status === 'rejected') {
|
|
try { console.warn('[email-attendees] Failed for', recipients[i]?.email, r.reason?.message || r.reason); } catch {}
|
|
}
|
|
});
|
|
})();
|
|
} catch (error) {
|
|
return res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Send WhatsApp message to event attendees with filters and preview support
|
|
// @route POST /api/events/:id/whatsapp-attendees
|
|
// @access Private/Supervisor or Admin
|
|
const whatsappEventAttendees = async (req, res) => {
|
|
try {
|
|
const eventId = req.params.id;
|
|
const { message, filter, dryRun, template } = req.body || {};
|
|
|
|
if (template !== 'tickets' && !message) {
|
|
res.status(400);
|
|
throw new Error('message is required unless template is "tickets"');
|
|
}
|
|
|
|
const event = await prisma.event.findUnique({ where: { id: eventId } });
|
|
if (!event) { res.status(404); throw new Error('Event not found'); }
|
|
|
|
const where = { eventId };
|
|
const statusFilter = (filter && filter.status) ? String(filter.status) : 'any';
|
|
if (statusFilter === 'paid') {
|
|
where.status = 'paid';
|
|
} else if (statusFilter === 'unpaid') {
|
|
where.status = { in: ['pending', 'partial_paid'] };
|
|
} else if (statusFilter === 'partial_paid') {
|
|
where.status = 'partial_paid';
|
|
} else if (statusFilter === 'cancelled') {
|
|
where.status = 'cancelled';
|
|
}
|
|
|
|
const attendeeIds = Array.isArray(filter?.attendeeIds) ? filter.attendeeIds.filter((x) => typeof x === 'string' && x.trim().length > 0) : [];
|
|
if (attendeeIds.length > 0) {
|
|
where.userId = { in: attendeeIds };
|
|
}
|
|
|
|
const nameQ = (filter && filter.name) ? String(filter.name).trim() : '';
|
|
const phoneQ = (filter && filter.phone) ? String(filter.phone).trim() : '';
|
|
|
|
const registrations = await prisma.registration.findMany({
|
|
where,
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
|
|
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
|
payments: true,
|
|
},
|
|
orderBy: { createdAt: 'asc' }
|
|
});
|
|
|
|
const { isValidZAPhone, normalizeZAPhone } = require('../utils/whatsapp');
|
|
|
|
let filtered = registrations.filter(r => {
|
|
if (!r.user) return false;
|
|
if (r.user.email && r.user.email.endsWith('@guest.local')) return false;
|
|
if (r.user.isActive === false) return false;
|
|
if (!isValidZAPhone(r.user.phoneNumber)) return false;
|
|
return true;
|
|
});
|
|
|
|
if (nameQ) {
|
|
const q = nameQ.toLowerCase();
|
|
filtered = filtered.filter(r => (r.user?.name || '').toLowerCase().includes(q));
|
|
}
|
|
if (phoneQ) {
|
|
filtered = filtered.filter(r => (r.user?.phoneNumber || '').includes(phoneQ));
|
|
}
|
|
|
|
// Unique recipients by phone number
|
|
const uniqMap = new Map();
|
|
for (const r of filtered) {
|
|
const phone = normalizeZAPhone(r.user?.phoneNumber);
|
|
if (!phone) continue;
|
|
if (!uniqMap.has(phone)) {
|
|
uniqMap.set(phone, { phone, name: r.user?.name || '', userId: r.user?.id, registrationId: r.id });
|
|
}
|
|
}
|
|
const recipients = Array.from(uniqMap.values());
|
|
|
|
if (dryRun) {
|
|
return res.json({ eventId, matched: recipients.length, recipients: recipients.slice(0, 20) });
|
|
}
|
|
|
|
const { sendText } = require('../utils/whatsapp');
|
|
const { computeRegistrationTotalDue } = require('../utils/pricing');
|
|
const { replacePlaceholders } = require('../utils/placeholders');
|
|
|
|
function fmtAmount(amt) {
|
|
const n = Number(amt || 0);
|
|
return `R${n.toFixed(2)}`;
|
|
}
|
|
function fmtDate(d) {
|
|
try { return new Date(d).toLocaleString(); } catch { return String(d); }
|
|
}
|
|
|
|
const regsByPhone = new Map();
|
|
for (const r of registrations) {
|
|
const phone = normalizeZAPhone(r.user?.phoneNumber);
|
|
if (!phone) continue;
|
|
if (!regsByPhone.has(phone)) regsByPhone.set(phone, []);
|
|
regsByPhone.get(phone).push(r);
|
|
}
|
|
|
|
const eventTitle = event?.title || 'the event';
|
|
const eventStart = event?.startDate ? fmtDate(event.startDate) : '';
|
|
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
|
const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`;
|
|
|
|
// Generates a live Yoco checkout link for the recipient's first registration with an
|
|
// outstanding balance — only called when the template actually uses {{payment.link}}.
|
|
function makePaymentLinkResolver(regs) {
|
|
return async () => {
|
|
const reg = (regs || []).find(r => {
|
|
const due = computeRegistrationTotalDue(r, new Date());
|
|
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
|
return due - paid > 0.01;
|
|
});
|
|
if (!reg) return '';
|
|
const { createRegistrationCheckoutInternal } = require('./paymentController');
|
|
const result = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
|
|
successUrl: `${baseUrl}/payment/success`,
|
|
cancelUrl: `${baseUrl}/payment/cancel`,
|
|
failureUrl: `${baseUrl}/payment/failure`,
|
|
});
|
|
return result.redirectUrl;
|
|
};
|
|
}
|
|
|
|
// Fire-and-forget: respond immediately with a queued count, then send in the background.
|
|
if (template === 'tickets') {
|
|
const { emailTickets } = require('./ticketController');
|
|
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: 'tickets' });
|
|
(async () => {
|
|
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
|
const regs = regsByPhone.get(rcpt.phone) || [];
|
|
for (const reg of regs) {
|
|
const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } };
|
|
const mockRes = { status: () => mockRes, json: () => {} };
|
|
await emailTickets(mockReq, mockRes);
|
|
}
|
|
}));
|
|
results.forEach((r, i) => {
|
|
if (r.status === 'rejected') {
|
|
try { console.warn('[whatsapp-attendees tickets] Failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {}
|
|
}
|
|
});
|
|
})();
|
|
return;
|
|
}
|
|
|
|
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: template || 'custom' });
|
|
|
|
(async () => {
|
|
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
|
const regs = regsByPhone.get(rcpt.phone) || [];
|
|
let totalDue = 0; let totalPaid = 0;
|
|
for (const r of regs) {
|
|
const due = computeRegistrationTotalDue(r, new Date());
|
|
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
|
totalDue += due; totalPaid += paid;
|
|
}
|
|
const balance = Math.max(totalDue - totalPaid, 0);
|
|
const ctx = {
|
|
name: rcpt.name || '',
|
|
eventTitle,
|
|
eventStart,
|
|
eventLink,
|
|
balance,
|
|
balanceFmt: fmtAmount(balance),
|
|
paymentLinkResolver: makePaymentLinkResolver(regs),
|
|
};
|
|
|
|
let msg = message;
|
|
if (!msg && template === 'payment_reminder') {
|
|
msg = `Hi {{name}}\n\nThis is a friendly reminder that you have an outstanding balance of {{balance}} for {{event.title}}.\nEvent starts: {{event.start}}\n\nPlease settle your balance to secure your tickets. Thank you!`;
|
|
} else if (!msg && template === 'event_reminder') {
|
|
msg = `Hi {{name}}\n\nA quick reminder about {{event.title}}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`;
|
|
}
|
|
|
|
const finalMessage = await replacePlaceholders(msg || '', ctx);
|
|
await sendText(rcpt.phone, finalMessage);
|
|
}));
|
|
results.forEach((r, i) => {
|
|
if (r.status === 'rejected') {
|
|
try { console.warn('[whatsapp-attendees] Failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {}
|
|
}
|
|
});
|
|
})();
|
|
} catch (error) {
|
|
return res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Schedule email to attendees at a specific date/time
|
|
// @route POST /api/events/:id/email-attendees/schedule
|
|
// @access Private/Supervisor or Admin
|
|
const scheduleEmailEventAttendees = async (req, res) => {
|
|
try {
|
|
const eventId = req.params.id;
|
|
const { scheduledAt, subject, html, text, filter, template } = req.body || {};
|
|
|
|
// Validate event
|
|
const event = await prisma.event.findUnique({ where: { id: eventId } });
|
|
if (!event) { res.status(404); throw new Error('Event not found'); }
|
|
|
|
// Validate date
|
|
if (!scheduledAt) { res.status(400); throw new Error('scheduledAt is required'); }
|
|
const when = new Date(scheduledAt);
|
|
if (isNaN(when.getTime())) { res.status(400); throw new Error('scheduledAt must be a valid ISO date-time'); }
|
|
|
|
// For non-ticket templates or custom messages, require subject and content
|
|
if ((template !== 'tickets') && (!subject || !(html || text))) {
|
|
res.status(400);
|
|
throw new Error('Subject and message (html or text) are required unless template is "tickets"');
|
|
}
|
|
|
|
// Build payload matching emailEventAttendees body
|
|
const payload = { subject, html, text, filter: filter || {}, template };
|
|
|
|
const { addJob } = require('../utils/scheduledEmails');
|
|
const created = addJob({
|
|
eventId,
|
|
createdById: req.user?.id || null,
|
|
scheduledAt: when.toISOString(),
|
|
payload,
|
|
});
|
|
|
|
return res.status(201).json({ message: 'Email scheduled', job: created });
|
|
} catch (error) {
|
|
return res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Schedule WhatsApp message to attendees at a specific date/time
|
|
// @route POST /api/events/:id/whatsapp-attendees/schedule
|
|
// @access Private/Supervisor or Admin
|
|
const scheduleWhatsappEventAttendees = async (req, res) => {
|
|
try {
|
|
const eventId = req.params.id;
|
|
const { scheduledAt, message, filter, template } = req.body || {};
|
|
|
|
const event = await prisma.event.findUnique({ where: { id: eventId } });
|
|
if (!event) { res.status(404); throw new Error('Event not found'); }
|
|
|
|
if (!scheduledAt) { res.status(400); throw new Error('scheduledAt is required'); }
|
|
const when = new Date(scheduledAt);
|
|
if (isNaN(when.getTime())) { res.status(400); throw new Error('scheduledAt must be a valid ISO date-time'); }
|
|
|
|
if (template !== 'tickets' && !message) {
|
|
res.status(400);
|
|
throw new Error('message is required unless template is "tickets"');
|
|
}
|
|
|
|
const payload = { message, filter: filter || {}, template };
|
|
|
|
const { addJob } = require('../utils/scheduledEmails');
|
|
const created = addJob({
|
|
eventId,
|
|
channel: 'whatsapp',
|
|
createdById: req.user?.id || null,
|
|
scheduledAt: when.toISOString(),
|
|
payload,
|
|
});
|
|
|
|
return res.status(201).json({ message: 'WhatsApp message scheduled', job: created });
|
|
} catch (error) {
|
|
return res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @desc Get event by redirectUrl (alias)
|
|
* @route GET /api/events/by-alias/:redirectUrl
|
|
* @access Public
|
|
*/
|
|
const getEventByAlias = async (req, res) => {
|
|
const { redirectUrl } = req.params;
|
|
|
|
try {
|
|
const event = await prisma.event.findFirst({
|
|
where: {
|
|
redirectUrl: {
|
|
equals: redirectUrl,
|
|
mode: 'insensitive',
|
|
},
|
|
isActive: true,
|
|
},
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
description: true,
|
|
startDate: true,
|
|
endDate: true,
|
|
registrationDeadline: true,
|
|
goLiveAt: true,
|
|
price: true,
|
|
picture: true,
|
|
isActive: true,
|
|
redirectUrl: true,
|
|
},
|
|
});
|
|
|
|
if (!event) {
|
|
return res.status(404).json({ message: 'Event not found or inactive' });
|
|
}
|
|
|
|
const now = new Date();
|
|
const start = event.startDate ? new Date(event.startDate) : null;
|
|
const end = event.endDate ? new Date(event.endDate) : null;
|
|
const isLive =
|
|
(!start || start >= now) &&
|
|
(!end || end >= now);
|
|
|
|
res.json({ ...event, isLive });
|
|
} catch (error) {
|
|
console.error('[getEventByAlias] Error:', error);
|
|
res.status(500).json({ message: 'Internal server error' });
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
createEvent,
|
|
getEvents,
|
|
getAllEvents,
|
|
getEventsAll,
|
|
getEventById,
|
|
updateEvent,
|
|
getEventNotifyRecipients,
|
|
updateEventNotifyRecipients,
|
|
deleteEvent,
|
|
createEventOption,
|
|
updateEventOption,
|
|
deleteEventOption,
|
|
createOptionVariant,
|
|
updateOptionVariant,
|
|
deleteOptionVariant,
|
|
listEventAttachments,
|
|
uploadEventAttachment,
|
|
deleteEventAttachment,
|
|
attachmentsStatus,
|
|
attachmentsSync,
|
|
emailEventAttendees,
|
|
scheduleEmailEventAttendees,
|
|
whatsappEventAttendees,
|
|
scheduleWhatsappEventAttendees,
|
|
getEventByAlias
|
|
}; |