- Privacy page: news opt-in per channel with history, download my data (JSON + PDF), correction and deletion requests - Bulk messages are an Event update or News & announcements; news only reaches people who opted in, with unsubscribe links, one-click List-Unsubscribe headers and "Reply STOP" on WhatsApp - WhatsApp STOP via WAWP's message webhook, checked against a secret in the webhook URL - Sign-up opt-in box; existing accounts kept opted in and asked once - Admin -> Data requests with 30-day due dates and a daily reminder job - Deleting an account is a request; anonymising now covers attendees, form answers, consent details, payer details in provider payloads, etc. - Unit tests (privacy.test.js) and e2e (privacy.spec.ts); docs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
194 lines
7.6 KiB
JavaScript
194 lines
7.6 KiB
JavaScript
const prisma = require('../config/db');
|
|
const { normaliseKind, filterRecipients, whatsAppNewsFooter } = require('../utils/privacy');
|
|
|
|
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 kind = normaliseKind(req.body?.kind) || 'news';
|
|
const { recipients, skipped } = await filterRecipients(Array.from(map.values()), { channel: 'whatsapp', kind });
|
|
|
|
return res.json({ kind, matched: recipients.length, skipped, 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 || '' });
|
|
}
|
|
// News unless marked as an event update; news skips people who haven't opted in to
|
|
// WhatsApp news, replied STOP or unsubscribed.
|
|
const kind = normaliseKind(req.body?.kind) || 'news';
|
|
const { recipients, skipped } = await filterRecipients(Array.from(map.values()), { channel: 'whatsapp', kind });
|
|
if (recipients.length === 0) {
|
|
return res.status(400).json({ message: skipped ? 'Everyone selected has unsubscribed from WhatsApp news.' : '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, kind === 'news' ? finalMessage + whatsAppNewsFooter(rcpt) : 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({ kind, matched: recipients.length, skipped, 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, kind } = 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, kind };
|
|
|
|
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 }; |