const prisma = require('../config/db'); // Escape user-controlled strings before inserting them into HTML function escapeHtml(str) { return String(str || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // Utilities shared with attendees emailing function fmtDate(d) { try { return new Date(d).toLocaleString(); } catch { return String(d); } } function getFrontendBaseUrl() { const base = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001'; return String(base).replace(/\/$/, ''); } function buildEventContext(event) { if (!event) return { eventTitle: '', eventStart: '', eventLink: '', eventLinkHtml: '' }; const eventTitle = event.title || ''; const eventStart = event.startDate ? fmtDate(event.startDate) : ''; const eventLink = `${getFrontendBaseUrl()}/events/${encodeURIComponent(event.id)}`; const eventLinkHtml = `${eventLink}`; return { eventTitle, eventStart, eventLink, eventLinkHtml }; } const { replacePlaceholders } = require('../utils/placeholders'); function parseFreeformEmails(lines) { // Supports formats: // - email@example.com // - Name // - "Name" const recipients = []; const input = Array.isArray(lines) ? lines : String(lines || '').split(/\r?\n/); for (const raw of input) { const s = String(raw || '').trim(); if (!s) continue; let name = ''; let email = ''; const m = s.match(/^(.*?)<\s*([^>\s]+@[^>\s]+)\s*>\s*$/); if (m) { name = m[1].trim().replace(/^"|"$/g, '').trim(); email = m[2].trim(); } else { // If it just looks like an email, accept it const em = s.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/) ? s : ''; if (em) email = em; else continue; } recipients.push({ email, name }); } return recipients; } // @desc Preview broadcast recipients and sample // @route POST /api/broadcasts/preview // @access Private/Supervisor or Admin const previewBroadcast = async (req, res) => { try { const { userIds, emails, eventId } = req.body || {}; // Resolve users const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : []; let users = []; if (ids.length) { users = await prisma.user.findMany({ where: { id: { in: ids } }, select: { id: true, name: true, email: true } }); } // Parse freeform emails const extra = parseFreeformEmails(emails); // Merge and de-duplicate by email const map = new Map(); for (const u of users) { const email = String(u.email || '').trim(); if (!email) continue; if (!map.has(email)) map.set(email, { email, name: u.name || '' }); } for (const r of extra) { const email = String(r.email || '').trim(); if (!email) continue; if (!map.has(email)) map.set(email, { email, name: r.name || '' }); } const recipients = Array.from(map.values()); // Optionally resolve event info for link/title placeholder let event = null; if (eventId && typeof eventId === 'string') { event = await prisma.event.findUnique({ where: { id: eventId } }); } const eventCtx = buildEventContext(event); return res.json({ matched: recipients.length, recipients: recipients.slice(0, 20), event: event ? { id: event.id, title: event.title } : null, placeholders: ['{{name}}','{{event.title}}','{{event.start}}','{{event.link}}'] }); } catch (error) { return res.status(400).json({ message: error.message }); } }; // @desc Send broadcast now // @route POST /api/broadcasts/send // @access Private/Supervisor or Admin const sendBroadcast = async (req, res) => { try { const { subject, html, text, userIds, emails, eventId } = req.body || {}; if (!subject || !(html || text)) { return res.status(400).json({ message: 'Subject and message (html or text) are required' }); } // Resolve recipients similar to preview const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : []; let users = []; if (ids.length) { users = await prisma.user.findMany({ where: { id: { in: ids } }, select: { id: true, name: true, email: true } }); } const extra = parseFreeformEmails(emails); const map = new Map(); for (const u of users) { const email = String(u.email || '').trim(); if (!email) continue; if (!map.has(email)) map.set(email, { email, name: u.name || '' }); } for (const r of extra) { const email = String(r.email || '').trim(); if (!email) continue; if (!map.has(email)) map.set(email, { email, name: r.name || '' }); } const recipients = Array.from(map.values()); if (recipients.length === 0) { return res.status(400).json({ message: 'No valid recipients' }); } // Resolve event let event = null; if (eventId && typeof eventId === 'string') { event = await prisma.event.findUnique({ where: { id: eventId } }); } const eventCtx = buildEventContext(event); const { sendMail } = require('../utils/email'); // Send all emails in parallel instead of sequentially — critical for large recipient lists const results = await Promise.allSettled(recipients.map(async rcpt => { const ctxBase = { name: rcpt.name || '', eventTitle: eventCtx.eventTitle, eventStart: eventCtx.eventStart, eventLink: eventCtx.eventLink, }; const ctxForHtml = html ? { name: escapeHtml(rcpt.name || ''), eventTitle: escapeHtml(eventCtx.eventTitle), eventStart: escapeHtml(eventCtx.eventStart), eventLink: escapeHtml(eventCtx.eventLink), eventLinkHtml: eventCtx.eventLinkHtml, } : ctxBase; const finalSubject = await replacePlaceholders(subject, ctxBase); const finalHtml = html ? await replacePlaceholders(html, ctxForHtml) : undefined; const finalText = (!html ? await replacePlaceholders(text || '', ctxBase) : undefined); await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText }); })); const sent = results.filter(r => r.status === 'fulfilled').length; results.forEach((r, i) => { if (r.status === 'rejected') { try { console.warn('[broadcast] failed to send to', recipients[i]?.email, r.reason?.message || r.reason); } catch {} } }); return res.json({ matched: recipients.length, sent }); } catch (error) { return res.status(400).json({ message: error.message }); } }; // @desc Schedule a broadcast // @route POST /api/broadcasts/schedule // @access Private/Supervisor or Admin const scheduleBroadcast = async (req, res) => { try { const { scheduledAt, subject, html, text, userIds, emails, eventId } = req.body || {}; if (!scheduledAt) return res.status(400).json({ message: 'scheduledAt is required' }); const when = new Date(scheduledAt); if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' }); if (!subject || !(html || text)) return res.status(400).json({ message: 'Subject and message (html or text) are required' }); const payload = { subject, html, text, userIds, emails, eventId }; const { addJob } = require('../utils/scheduledEmails'); const created = addJob({ broadcast: true, scheduledAt: when.toISOString(), createdById: req.user?.id || null, payload, }); return res.status(201).json({ message: 'Broadcast scheduled', job: created }); } catch (error) { return res.status(400).json({ message: error.message }); } }; module.exports = { previewBroadcast, sendBroadcast, scheduleBroadcast };