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>
229 lines
8.5 KiB
JavaScript
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, '&')
|
|
.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 = `<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 };
|