From b3ff2b9c5ec22e2bc6b45e557cf2e596139aaf20 Mon Sep 17 00:00:00 2001 From: joshua Date: Thu, 6 Aug 2026 23:37:35 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 9 +++++++++ .../src/controllers/broadcastController.js | 20 +++++++++++++++++++ backend/src/controllers/eventController.js | 11 ++++++++++ .../controllers/scheduledEmailsController.js | 20 +++++++------------ .../whatsappBroadcastController.js | 19 ++++++++++++++++++ backend/src/index.js | 3 ++- backend/src/utils/scheduledEmails.js | 19 ++++++++++++++++++ .../supervisor/email-attendees/page.tsx | 9 ++++++--- .../supervisor/whatsapp-attendees/page.tsx | 13 ++++++------ 9 files changed, 100 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0061f7..b63825b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed + +- Scheduling a WhatsApp message was silently sent as an email instead: 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. Scheduled WhatsApp jobs now correctly send via WhatsApp. + +### Added + +- Scheduled emails/WhatsApp messages are now automatically purged from storage 24 hours after they're sent, instead of accumulating indefinitely. +- The "Manage scheduled" lists on the Email Attendees and WhatsApp Attendees pages now show who each scheduled job will be sent to, and each tab only shows jobs for its own channel (previously both tabs showed the same unfiltered list). + ## [1.5.0] - 2026-08-06 ### Added diff --git a/backend/src/controllers/broadcastController.js b/backend/src/controllers/broadcastController.js index f2e02e1..fd84fce 100644 --- a/backend/src/controllers/broadcastController.js +++ b/backend/src/controllers/broadcastController.js @@ -57,6 +57,23 @@ function parseFreeformEmails(lines) { return recipients; } +// Human-readable summary of who a broadcast will go to, for the scheduled-jobs admin UI +async function describeBroadcastRecipients({ userIds, emails }) { + 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` : '')); + } + if (Array.isArray(emails) && emails.length) { + parts.push(`${emails.length} email address${emails.length === 1 ? '' : 'es'}`); + } + } catch {} + return parts.length ? parts.join('; ') : 'No recipients'; +} + // @desc Preview broadcast recipients and sample // @route POST /api/broadcasts/preview // @access Private/Supervisor or Admin @@ -191,11 +208,14 @@ const scheduleBroadcast = async (req, res) => { const payload = { subject, html, text, userIds, emails, eventId }; + const recipientSummary = await describeBroadcastRecipients({ userIds, emails: parseFreeformEmails(emails) }); + const { addJob } = require('../utils/scheduledEmails'); const created = addJob({ broadcast: true, scheduledAt: when.toISOString(), createdById: req.user?.id || null, + recipientSummary, payload, }); diff --git a/backend/src/controllers/eventController.js b/backend/src/controllers/eventController.js index f4137ae..34a17a9 100644 --- a/backend/src/controllers/eventController.js +++ b/backend/src/controllers/eventController.js @@ -1569,6 +1569,15 @@ const whatsappEventAttendees = async (req, res) => { } }; +// Human-readable summary of who an attendees-scoped send will go to, for the scheduled-jobs admin UI +function describeAttendeeFilter(filter) { + if (Array.isArray(filter?.attendeeIds) && filter.attendeeIds.length > 0) { + return `${filter.attendeeIds.length} selected attendee${filter.attendeeIds.length === 1 ? '' : 's'}`; + } + const labels = { paid: 'Paid attendees', unpaid: 'Unpaid attendees', partial_paid: 'Partially paid attendees', cancelled: 'Cancelled registrations' }; + return labels[filter?.status] || 'All attendees'; +} + // @desc Schedule email to attendees at a specific date/time // @route POST /api/events/:id/email-attendees/schedule // @access Private/Supervisor or Admin @@ -1600,6 +1609,7 @@ const scheduleEmailEventAttendees = async (req, res) => { eventId, createdById: req.user?.id || null, scheduledAt: when.toISOString(), + recipientSummary: `${event.title} — ${describeAttendeeFilter(filter)}`, payload, }); @@ -1637,6 +1647,7 @@ const scheduleWhatsappEventAttendees = async (req, res) => { channel: 'whatsapp', createdById: req.user?.id || null, scheduledAt: when.toISOString(), + recipientSummary: `${event.title} — ${describeAttendeeFilter(filter)}`, payload, }); diff --git a/backend/src/controllers/scheduledEmailsController.js b/backend/src/controllers/scheduledEmailsController.js index 0276ca8..4d6b34d 100644 --- a/backend/src/controllers/scheduledEmailsController.js +++ b/backend/src/controllers/scheduledEmailsController.js @@ -11,6 +11,8 @@ function toClient(job) { kind, eventId: job.eventId || null, broadcast: !!job.broadcast, + channel: job.channel || 'email', + recipient: job.recipientSummary || null, scheduledAt: job.scheduledAt, createdAt: job.createdAt, status: job.status, @@ -23,21 +25,13 @@ function toClient(job) { }; } -// GET /api/scheduled-emails -// Returns jobs excluding emails sent more than a week ago +// GET /api/scheduled-emails?channel=email|whatsapp +// Sent jobs are purged from storage 24h after sending, so nothing older than that is ever returned here const listScheduledEmails = async (req, res) => { try { - const raw = listJobs(); - const now = new Date(); - const weekMs = 7 * 24 * 60 * 60 * 1000; - const filtered = raw.filter(j => { - if (j.status === 'sent' && j.sentAt) { - const sentAt = new Date(j.sentAt).getTime(); - return (now.getTime() - sentAt) <= weekMs; - } - // Include queued, sending, error by default - return true; - }) + const { channel } = req.query || {}; + const raw = listJobs(channel ? { channel: String(channel) } : {}); + const filtered = raw // Provide most-relevant first: queued -> sending -> error -> recent sent .sort((a, b) => { const order = { queued: 0, sending: 1, error: 2, sent: 3 }; diff --git a/backend/src/controllers/whatsappBroadcastController.js b/backend/src/controllers/whatsappBroadcastController.js index 43151c4..a9eb7a2 100644 --- a/backend/src/controllers/whatsappBroadcastController.js +++ b/backend/src/controllers/whatsappBroadcastController.js @@ -36,6 +36,22 @@ function parseFreeformPhones(lines) { 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 @@ -152,12 +168,15 @@ const scheduleWhatsAppBroadcast = async (req, res) => { 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, }); diff --git a/backend/src/index.js b/backend/src/index.js index 255bb15..834e6c0 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1192,13 +1192,14 @@ app.listen(PORT, () => { try { const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false'; if (enabled) { - const { getDueJobs, updateJob } = require('./utils/scheduledEmails'); + const { getDueJobs, updateJob, purgeSentJobs } = require('./utils/scheduledEmails'); const { emailEventAttendees, whatsappEventAttendees } = require('./controllers/eventController'); const { sendBroadcast } = require('./controllers/broadcastController'); const { sendWhatsAppBroadcast } = require('./controllers/whatsappBroadcastController'); const intervalMs = parseInt(process.env.SCHEDULED_EMAILS_INTERVAL_MS || '30000', 10); setInterval(async () => { try { + try { purgeSentJobs(24 * 60 * 60 * 1000); } catch (e) { console.warn('[scheduled emails] purge failed:', e?.message || e); } const due = getDueJobs(new Date()); if (!due || due.length === 0) return; for (const job of due) { diff --git a/backend/src/utils/scheduledEmails.js b/backend/src/utils/scheduledEmails.js index 94be64c..dc40c41 100644 --- a/backend/src/utils/scheduledEmails.js +++ b/backend/src/utils/scheduledEmails.js @@ -46,6 +46,8 @@ function addJob(job) { id, eventId: job.eventId || null, broadcast: !!job.broadcast, + channel: job.channel || 'email', + recipientSummary: job.recipientSummary || null, createdById: job.createdById || null, scheduledAt: job.scheduledAt, createdAt: now.toISOString(), @@ -66,10 +68,26 @@ function listJobs(filter = {}) { return jobs.filter(j => { if (filter.status && j.status !== filter.status) return false; if (filter.eventId && j.eventId !== filter.eventId) return false; + if (filter.channel && (j.channel || 'email') !== filter.channel) return false; return true; }); } +/** + * Permanently remove jobs that finished sending more than maxAgeMs ago. + * Only touches 'sent' jobs - queued/sending/error jobs are left for admins to review. + */ +function purgeSentJobs(maxAgeMs = 24 * 60 * 60 * 1000) { + const jobs = loadAll(); + const now = Date.now(); + const kept = jobs.filter(j => { + if (j.status !== 'sent' || !j.sentAt) return true; + return (now - new Date(j.sentAt).getTime()) <= maxAgeMs; + }); + if (kept.length !== jobs.length) saveAll(kept); + return jobs.length - kept.length; +} + function getDueJobs(now = new Date()) { const jobs = loadAll(); const t = now instanceof Date ? now : new Date(now); @@ -105,4 +123,5 @@ module.exports = { updateJob, getJob, deleteJob, + purgeSentJobs, }; diff --git a/frontend/src/app/dashboard/supervisor/email-attendees/page.tsx b/frontend/src/app/dashboard/supervisor/email-attendees/page.tsx index c2c2c52..eb452d1 100644 --- a/frontend/src/app/dashboard/supervisor/email-attendees/page.tsx +++ b/frontend/src/app/dashboard/supervisor/email-attendees/page.tsx @@ -261,7 +261,7 @@ function EmailAttendeesPageInner() { }; // Scheduled jobs state - type ScheduledJob = { id: string; kind: 'attendees'|'broadcast'|'unknown'; eventId?: string|null; broadcast?: boolean; scheduledAt: string; createdAt: string; status: 'queued'|'sending'|'sent'|'error'; attempts: number; sentAt?: string|null; lastError?: string|null; subject?: string; hasHtml?: boolean; hasText?: boolean }; + type ScheduledJob = { id: string; kind: 'attendees'|'broadcast'|'unknown'; eventId?: string|null; broadcast?: boolean; channel?: string; recipient?: string|null; scheduledAt: string; createdAt: string; status: 'queued'|'sending'|'sent'|'error'; attempts: number; sentAt?: string|null; lastError?: string|null; subject?: string; hasHtml?: boolean; hasText?: boolean }; const [scheduled, setScheduled] = useState([]); const [loadingScheduled, setLoadingScheduled] = useState(false); const [editing, setEditing] = useState(null); @@ -274,7 +274,7 @@ function EmailAttendeesPageInner() { try { if (!token) return; setLoadingScheduled(true); - const res = await apiFetch<{jobs: ScheduledJob[]}>(`/api/scheduled-emails`, { authToken: token }); + const res = await apiFetch<{jobs: ScheduledJob[]}>(`/api/scheduled-emails?channel=email`, { authToken: token }); setScheduled(Array.isArray(res?.jobs) ? res.jobs : []); } catch (e) { // ignore here; surfaces via UI when tab open @@ -872,7 +872,7 @@ Jane Doe {loadingScheduled ? (
Loading…
) : scheduled.length === 0 ? ( -
No scheduled items. Items sent more than a week ago are hidden.
+
No scheduled emails. Sent items are cleared 24 hours after sending.
) : (
    {scheduled.map(job => ( @@ -882,6 +882,9 @@ Jane Doe {job.kind} {job.subject || '(no subject)'} +
    + To: {job.recipient || 'Unknown recipients'} +
    Status: {job.status} Scheduled: {(() => { try { return new Date(job.scheduledAt).toLocaleString(); } catch { return job.scheduledAt; } })()} diff --git a/frontend/src/app/dashboard/supervisor/whatsapp-attendees/page.tsx b/frontend/src/app/dashboard/supervisor/whatsapp-attendees/page.tsx index 363f69e..4ea9121 100644 --- a/frontend/src/app/dashboard/supervisor/whatsapp-attendees/page.tsx +++ b/frontend/src/app/dashboard/supervisor/whatsapp-attendees/page.tsx @@ -535,7 +535,7 @@ function WhatsAppAttendeesPageInner() { }; // ── Scheduled tab ────────────────────────────────────────────────────────── - type ScheduledJob = { id: string; kind: string; eventId?: string | null; broadcast?: boolean; channel?: string; scheduledAt: string; createdAt: string; status: string; attempts: number; sentAt?: string | null; lastError?: string | null; subject?: string; payload?: any }; + type ScheduledJob = { id: string; kind: string; eventId?: string | null; broadcast?: boolean; channel?: string; recipient?: string | null; scheduledAt: string; createdAt: string; status: string; attempts: number; sentAt?: string | null; lastError?: string | null; subject?: string; payload?: any }; const [scheduled, setScheduled] = useState([]); const [loadingScheduled, setLoadingScheduled] = useState(false); const [editing, setEditing] = useState(null); @@ -547,10 +547,8 @@ function WhatsAppAttendeesPageInner() { try { if (!token) return; setLoadingScheduled(true); - const res = await apiFetch<{ jobs: ScheduledJob[] }>(`/api/scheduled-emails`, { authToken: token }); - // Filter to only WhatsApp jobs - const all = Array.isArray(res?.jobs) ? res.jobs : []; - setScheduled(all.filter((j) => j.channel === "whatsapp" || (j.broadcast && j.channel === "whatsapp"))); + const res = await apiFetch<{ jobs: ScheduledJob[] }>(`/api/scheduled-emails?channel=whatsapp`, { authToken: token }); + setScheduled(Array.isArray(res?.jobs) ? res.jobs : []); } catch { } finally { setLoadingScheduled(false); } }; @@ -882,7 +880,7 @@ function WhatsAppAttendeesPageInner() { {loadingScheduled ? (
    Loading…
    ) : scheduled.length === 0 ? ( -
    No scheduled WhatsApp messages. Items sent more than a week ago are hidden.
    +
    No scheduled WhatsApp messages. Sent items are cleared 24 hours after sending.
    ) : (
      {scheduled.map((job) => ( @@ -892,6 +890,9 @@ function WhatsAppAttendeesPageInner() { {job.broadcast ? "broadcast" : "attendees"} {job.payload?.message ? String(job.payload.message).slice(0, 60) + (String(job.payload.message).length > 60 ? "…" : "") : "(no message)"}
    +
    + To: {job.recipient || "Unknown recipients"} +
    Status: {job.status} Scheduled: {(() => { try { return new Date(job.scheduledAt).toLocaleString(); } catch { return job.scheduledAt; } })()}