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>
189 lines
7.0 KiB
JavaScript
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 }; |