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 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 };