Files
hope-events/backend/src/controllers/whatsappBroadcastController.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

189 lines
7.0 KiB
JavaScript

const prisma = require('../config/db');
function fmtDate(d) {
try { return new Date(d).toLocaleString(); } catch { return String(d); }
}
function getFrontendBaseUrl() {
return String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
}
function replacePlaceholders(str, ctx) {
if (!str) return str;
return String(str)
.replace(/\{\{\s*name\s*\}\}/g, ctx.name || '')
.replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '')
.replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '')
.replace(/\{\{\s*event\.(link|url)\s*\}\}/g, ctx.eventLink || '');
}
function parseFreeformPhones(lines) {
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;
// Format: Name <phone> or just phone
const m = s.match(/^(.*?)<\s*([+\d\s()-]+)\s*>\s*$/);
if (m) {
recipients.push({ phone: m[2].trim(), name: m[1].trim().replace(/^"|"$/g, '').trim() });
} else {
// Accept raw phone-like strings
const digits = s.replace(/\D/g, '');
if (digits.length >= 9) recipients.push({ phone: s, name: '' });
}
}
return recipients;
}
// Human-readable summary of who a WhatsApp broadcast will go to, for the scheduled-jobs admin UI
async function describeBroadcastRecipients({ userIds, phones }) {
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` : ''));
}
const extra = parseFreeformPhones(phones);
if (extra.length) parts.push(`${extra.length} phone number${extra.length === 1 ? '' : 's'}`);
} catch {}
return parts.length ? parts.join('; ') : 'No recipients';
}
// @desc Preview WhatsApp broadcast recipients
// @route POST /api/whatsapp-broadcasts/preview
// @access Private/Supervisor or Admin
const previewWhatsAppBroadcast = async (req, res) => {
try {
const { userIds, phones, eventId } = req.body || {};
const { normalizeZAPhone } = require('../utils/whatsapp');
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, phoneNumber: true, notificationPreference: true }
});
}
const extra = parseFreeformPhones(phones);
const map = new Map();
for (const u of users) {
const phone = normalizeZAPhone(u.phoneNumber);
if (!phone) continue;
if (!map.has(phone)) map.set(phone, { phone, name: u.name || '' });
}
for (const r of extra) {
const phone = normalizeZAPhone(r.phone);
if (!phone) continue;
if (!map.has(phone)) map.set(phone, { phone, name: r.name || '' });
}
const recipients = Array.from(map.values());
return res.json({ matched: recipients.length, recipients: recipients.slice(0, 20) });
} catch (error) {
return res.status(400).json({ message: error.message });
}
};
// @desc Send WhatsApp broadcast now
// @route POST /api/whatsapp-broadcasts/send
// @access Private/Supervisor or Admin
const sendWhatsAppBroadcast = async (req, res) => {
try {
const { message, userIds, phones, eventId } = req.body || {};
if (!message) {
return res.status(400).json({ message: 'message is required' });
}
const { normalizeZAPhone, sendText } = require('../utils/whatsapp');
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, phoneNumber: true }
});
}
const extra = parseFreeformPhones(phones);
const map = new Map();
for (const u of users) {
const phone = normalizeZAPhone(u.phoneNumber);
if (!phone) continue;
if (!map.has(phone)) map.set(phone, { phone, name: u.name || '' });
}
for (const r of extra) {
const phone = normalizeZAPhone(r.phone);
if (!phone) continue;
if (!map.has(phone)) map.set(phone, { phone, name: r.name || '' });
}
const recipients = Array.from(map.values());
if (recipients.length === 0) {
return res.status(400).json({ message: 'No valid recipients with phone numbers' });
}
let event = null;
if (eventId && typeof eventId === 'string') {
event = await prisma.event.findUnique({ where: { id: eventId } });
}
const eventTitle = event?.title || '';
const eventStart = event?.startDate ? fmtDate(event.startDate) : '';
const eventLink = event ? `${getFrontendBaseUrl()}/events/${encodeURIComponent(event.id)}` : '';
const results = await Promise.allSettled(recipients.map(async rcpt => {
const ctx = { name: rcpt.name || '', eventTitle, eventStart, eventLink };
const finalMessage = replacePlaceholders(message, ctx);
await sendText(rcpt.phone, finalMessage);
}));
const sent = results.filter(r => r.status === 'fulfilled').length;
results.forEach((r, i) => {
if (r.status === 'rejected') {
try { console.warn('[wa-broadcast] failed for', recipients[i]?.phone, 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 WhatsApp broadcast
// @route POST /api/whatsapp-broadcasts/schedule
// @access Private/Supervisor or Admin
const scheduleWhatsAppBroadcast = async (req, res) => {
try {
const { scheduledAt, message, userIds, phones, 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 (!message) return res.status(400).json({ message: 'message is required' });
const payload = { message, userIds, phones, eventId };
const recipientSummary = await describeBroadcastRecipients({ userIds, phones });
const { addJob } = require('../utils/scheduledEmails');
const created = addJob({
broadcast: true,
channel: 'whatsapp',
scheduledAt: when.toISOString(),
createdById: req.user?.id || null,
recipientSummary,
payload,
});
return res.status(201).json({ message: 'WhatsApp broadcast scheduled', job: created });
} catch (error) {
return res.status(400).json({ message: error.message });
}
};
module.exports = { previewWhatsAppBroadcast, sendWhatsAppBroadcast, scheduleWhatsAppBroadcast };