Files
hope-events/backend/src/controllers/broadcastController.js
T
joshuaandClaude Sonnet 5 0de3f4be7d Fix financial double-counting, rebuild cashup accountability, and redesign the Reports page
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>
2026-08-04 14:52:54 +02:00

209 lines
7.6 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;
}
// @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 };