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 = `${eventLink}`; const promoTitle = promoEvent?.title || ''; const promoLink = promoEvent ? `${baseUrl}/events/${encodeURIComponent(promoEvent.id)}` : ''; const promoLinkHtml = promoLink ? `${promoLink}` : ''; 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 = `

Payment reminder

Hi {{name}},

You have an outstanding balance of {{balance}} for {{event.title}}.

${eventStart ? `

Event starts: {{event.start}}

` : ''}

Please settle your balance to secure your tickets. Thank you!

`; } 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 = `

Event reminder

Hi {{name}},

This is a reminder for {{event.title}}.

${eventStart ? `

Start: {{event.start}}

` : ''}

We look forward to seeing you!

`; } } // Always perform placeholder replacement on whatever we have const finalSubject = await replacePlaceholders(subj || '', ctxBase); const finalHtml = h ? await replacePlaceholders(h, ctxHtml) : undefined; const finalText = (!h ? await replacePlaceholders(t || '', ctxBase) : undefined); await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText }); })); results.forEach((r, i) => { if (r.status === 'rejected') { try { console.warn('[email-attendees] Failed for', recipients[i]?.email, r.reason?.message || r.reason); } catch {} } }); })(); } catch (error) { return res.status(400).json({ message: error.message }); } }; // @desc Send WhatsApp message to event attendees with filters and preview support // @route POST /api/events/:id/whatsapp-attendees // @access Private/Supervisor or Admin const whatsappEventAttendees = async (req, res) => { try { const eventId = req.params.id; const { message, filter, dryRun, template } = req.body || {}; if (template !== 'tickets' && !message) { res.status(400); throw new Error('message is required unless template is "tickets"'); } const event = await prisma.event.findUnique({ where: { id: eventId } }); if (!event) { res.status(404); throw new Error('Event not found'); } const where = { eventId }; const statusFilter = (filter && filter.status) ? String(filter.status) : 'any'; if (statusFilter === 'paid') { where.status = 'paid'; } else if (statusFilter === 'unpaid') { where.status = { in: ['pending', 'partial_paid'] }; } else if (statusFilter === 'partial_paid') { where.status = 'partial_paid'; } else if (statusFilter === 'cancelled') { where.status = 'cancelled'; } const attendeeIds = Array.isArray(filter?.attendeeIds) ? filter.attendeeIds.filter((x) => typeof x === 'string' && x.trim().length > 0) : []; if (attendeeIds.length > 0) { where.userId = { in: attendeeIds }; } const nameQ = (filter && filter.name) ? String(filter.name).trim() : ''; const phoneQ = (filter && filter.phone) ? String(filter.phone).trim() : ''; const registrations = await prisma.registration.findMany({ where, include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, payments: true, }, orderBy: { createdAt: 'asc' } }); const { isValidZAPhone, normalizeZAPhone } = require('../utils/whatsapp'); let filtered = registrations.filter(r => { if (!r.user) return false; if (r.user.email && r.user.email.endsWith('@guest.local')) return false; if (r.user.isActive === false) return false; if (!isValidZAPhone(r.user.phoneNumber)) return false; return true; }); if (nameQ) { const q = nameQ.toLowerCase(); filtered = filtered.filter(r => (r.user?.name || '').toLowerCase().includes(q)); } if (phoneQ) { filtered = filtered.filter(r => (r.user?.phoneNumber || '').includes(phoneQ)); } // Unique recipients by phone number const uniqMap = new Map(); for (const r of filtered) { const phone = normalizeZAPhone(r.user?.phoneNumber); if (!phone) continue; if (!uniqMap.has(phone)) { uniqMap.set(phone, { phone, name: r.user?.name || '', userId: r.user?.id, registrationId: r.id }); } } const recipients = Array.from(uniqMap.values()); if (dryRun) { return res.json({ eventId, matched: recipients.length, recipients: recipients.slice(0, 20) }); } const { sendText } = require('../utils/whatsapp'); const { computeRegistrationTotalDue } = require('../utils/pricing'); const { replacePlaceholders } = require('../utils/placeholders'); function fmtAmount(amt) { const n = Number(amt || 0); return `R${n.toFixed(2)}`; } function fmtDate(d) { try { return new Date(d).toLocaleString(); } catch { return String(d); } } const regsByPhone = new Map(); for (const r of registrations) { const phone = normalizeZAPhone(r.user?.phoneNumber); if (!phone) continue; if (!regsByPhone.has(phone)) regsByPhone.set(phone, []); regsByPhone.get(phone).push(r); } const eventTitle = event?.title || 'the event'; const eventStart = event?.startDate ? fmtDate(event.startDate) : ''; const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''); const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`; // Generates a live Yoco checkout link for the recipient's first registration with an // outstanding balance — only called when the template actually uses {{payment.link}}. function makePaymentLinkResolver(regs) { return async () => { const reg = (regs || []).find(r => { const due = computeRegistrationTotalDue(r, new Date()); const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0); return due - paid > 0.01; }); if (!reg) return ''; const { createRegistrationCheckoutInternal } = require('./paymentController'); const result = await createRegistrationCheckoutInternal(reg.id, reg.userId, { successUrl: `${baseUrl}/payment/success`, cancelUrl: `${baseUrl}/payment/cancel`, failureUrl: `${baseUrl}/payment/failure`, }); return result.redirectUrl; }; } // Fire-and-forget: respond immediately with a queued count, then send in the background. if (template === 'tickets') { const { emailTickets } = require('./ticketController'); res.json({ eventId, matched: recipients.length, queued: recipients.length, template: 'tickets' }); (async () => { const results = await Promise.allSettled(recipients.map(async rcpt => { const regs = regsByPhone.get(rcpt.phone) || []; for (const reg of regs) { const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } }; const mockRes = { status: () => mockRes, json: () => {} }; await emailTickets(mockReq, mockRes); } })); results.forEach((r, i) => { if (r.status === 'rejected') { try { console.warn('[whatsapp-attendees tickets] Failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {} } }); })(); return; } res.json({ eventId, matched: recipients.length, queued: recipients.length, template: template || 'custom' }); (async () => { const results = await Promise.allSettled(recipients.map(async rcpt => { const regs = regsByPhone.get(rcpt.phone) || []; let totalDue = 0; let totalPaid = 0; for (const r of regs) { const due = computeRegistrationTotalDue(r, new Date()); const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0); totalDue += due; totalPaid += paid; } const balance = Math.max(totalDue - totalPaid, 0); const ctx = { name: rcpt.name || '', eventTitle, eventStart, eventLink, balance, balanceFmt: fmtAmount(balance), paymentLinkResolver: makePaymentLinkResolver(regs), }; let msg = message; if (!msg && template === 'payment_reminder') { msg = `Hi {{name}}\n\nThis is a friendly reminder that you have an outstanding balance of {{balance}} for {{event.title}}.\nEvent starts: {{event.start}}\n\nPlease settle your balance to secure your tickets. Thank you!`; } else if (!msg && template === 'event_reminder') { msg = `Hi {{name}}\n\nA quick reminder about {{event.title}}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`; } const finalMessage = await replacePlaceholders(msg || '', ctx); await sendText(rcpt.phone, finalMessage); })); results.forEach((r, i) => { if (r.status === 'rejected') { try { console.warn('[whatsapp-attendees] Failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {} } }); })(); } catch (error) { return res.status(400).json({ message: error.message }); } }; // Human-readable summary of who an attendees-scoped send will go to, for the scheduled-jobs admin UI function describeAttendeeFilter(filter) { if (Array.isArray(filter?.attendeeIds) && filter.attendeeIds.length > 0) { return `${filter.attendeeIds.length} selected attendee${filter.attendeeIds.length === 1 ? '' : 's'}`; } const labels = { paid: 'Paid attendees', unpaid: 'Unpaid attendees', partial_paid: 'Partially paid attendees', cancelled: 'Cancelled registrations' }; return labels[filter?.status] || 'All attendees'; } // @desc Schedule email to attendees at a specific date/time // @route POST /api/events/:id/email-attendees/schedule // @access Private/Supervisor or Admin const scheduleEmailEventAttendees = async (req, res) => { try { const eventId = req.params.id; const { scheduledAt, subject, html, text, filter, template } = req.body || {}; // Validate event const event = await prisma.event.findUnique({ where: { id: eventId } }); if (!event) { res.status(404); throw new Error('Event not found'); } // Validate date if (!scheduledAt) { res.status(400); throw new Error('scheduledAt is required'); } const when = new Date(scheduledAt); if (isNaN(when.getTime())) { res.status(400); throw new Error('scheduledAt must be a valid ISO date-time'); } // For non-ticket templates or custom messages, require subject and content if ((template !== 'tickets') && (!subject || !(html || text))) { res.status(400); throw new Error('Subject and message (html or text) are required unless template is "tickets"'); } // Build payload matching emailEventAttendees body const payload = { subject, html, text, filter: filter || {}, template }; const { addJob } = require('../utils/scheduledEmails'); const created = addJob({ eventId, createdById: req.user?.id || null, scheduledAt: when.toISOString(), recipientSummary: `${event.title} — ${describeAttendeeFilter(filter)}`, payload, }); return res.status(201).json({ message: 'Email scheduled', job: created }); } catch (error) { return res.status(400).json({ message: error.message }); } }; // @desc Schedule WhatsApp message to attendees at a specific date/time // @route POST /api/events/:id/whatsapp-attendees/schedule // @access Private/Supervisor or Admin const scheduleWhatsappEventAttendees = async (req, res) => { try { const eventId = req.params.id; const { scheduledAt, message, filter, template } = req.body || {}; const event = await prisma.event.findUnique({ where: { id: eventId } }); if (!event) { res.status(404); throw new Error('Event not found'); } if (!scheduledAt) { res.status(400); throw new Error('scheduledAt is required'); } const when = new Date(scheduledAt); if (isNaN(when.getTime())) { res.status(400); throw new Error('scheduledAt must be a valid ISO date-time'); } if (template !== 'tickets' && !message) { res.status(400); throw new Error('message is required unless template is "tickets"'); } const payload = { message, filter: filter || {}, template }; const { addJob } = require('../utils/scheduledEmails'); const created = addJob({ eventId, channel: 'whatsapp', createdById: req.user?.id || null, scheduledAt: when.toISOString(), recipientSummary: `${event.title} — ${describeAttendeeFilter(filter)}`, payload, }); return res.status(201).json({ message: 'WhatsApp message scheduled', job: created }); } catch (error) { return res.status(400).json({ message: error.message }); } }; /** * @desc Get event by redirectUrl (alias) * @route GET /api/events/by-alias/:redirectUrl * @access Public */ const getEventByAlias = async (req, res) => { const { redirectUrl } = req.params; try { const event = await prisma.event.findFirst({ where: { redirectUrl: { equals: redirectUrl, mode: 'insensitive', }, isActive: true, }, select: { id: true, title: true, description: true, startDate: true, endDate: true, registrationDeadline: true, goLiveAt: true, price: true, picture: true, isActive: true, redirectUrl: true, }, }); if (!event) { return res.status(404).json({ message: 'Event not found or inactive' }); } const now = new Date(); const start = event.startDate ? new Date(event.startDate) : null; const end = event.endDate ? new Date(event.endDate) : null; const isLive = (!start || start >= now) && (!end || end >= now); res.json({ ...event, isLive }); } catch (error) { console.error('[getEventByAlias] Error:', error); res.status(500).json({ message: 'Internal server error' }); } }; module.exports = { createEvent, getEvents, getAllEvents, getEventsAll, getEventById, updateEvent, getEventNotifyRecipients, updateEventNotifyRecipients, deleteEvent, createEventOption, updateEventOption, deleteEventOption, createOptionVariant, updateOptionVariant, deleteOptionVariant, listEventAttachments, uploadEventAttachment, deleteEventAttachment, attachmentsStatus, attachmentsSync, emailEventAttendees, scheduleEmailEventAttendees, whatsappEventAttendees, scheduleWhatsappEventAttendees, getEventByAlias };