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

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

229 lines
8.5 KiB
JavaScript

const prisma = require('../config/db');
// Escape user-controlled strings before inserting them into HTML
function escapeHtml(str) {
return String(str || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
// 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 = `<a href="${eventLink}">${eventLink}</a>`;
return { eventTitle, eventStart, eventLink, eventLinkHtml };
}
const { replacePlaceholders } = require('../utils/placeholders');
function parseFreeformEmails(lines) {
// Supports formats:
// - email@example.com
// - Name <email@example.com>
// - "Name" <email@example.com>
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;
}
// Human-readable summary of who a broadcast will go to, for the scheduled-jobs admin UI
async function describeBroadcastRecipients({ userIds, emails }) {
const parts = [];
try {
const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : [];
if (ids.length) {
const users = await prisma.user.findMany({ where: { id: { in: ids } }, select: { name: true } });
const names = users.map(u => u.name).filter(Boolean);
parts.push(names.slice(0, 3).join(', ') + (names.length > 3 ? ` +${names.length - 3} more` : ''));
}
if (Array.isArray(emails) && emails.length) {
parts.push(`${emails.length} email address${emails.length === 1 ? '' : 'es'}`);
}
} catch {}
return parts.length ? parts.join('; ') : 'No 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 recipientSummary = await describeBroadcastRecipients({ userIds, emails: parseFreeformEmails(emails) });
const { addJob } = require('../utils/scheduledEmails');
const created = addJob({
broadcast: true,
scheduledAt: when.toISOString(),
createdById: req.user?.id || null,
recipientSummary,
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 };