Compare commits

..
Author SHA1 Message Date
joshuaandClaude Sonnet 5 be499f0d66 Fix scheduled job list/edit dropping message content
toClient() never returned the message body (html/text for email,
message for WhatsApp), so "Manage scheduled" showed nothing to view
or edit even though the content existed in storage. Editing a
scheduled WhatsApp message also saved to the wrong payload field
(text instead of message), silently discarding the edit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 00:00:43 +02:00
joshuaandClaude Sonnet 5 75b2f8ccc5 Bump version to 1.5.1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 23:41:43 +02:00
joshua 4f003f1628 Merge branch 'fix/whatsapp-scheduling-channel-bug' 2026-08-06 23:40:32 +02:00
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
joshuaandClaude Sonnet 5 0833888eaa Bump version to 1.5.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 15:29:17 +02:00
joshuaandClaude Sonnet 5 b626404640 Merge branch 'feature/site-redesign' (1.5.0)
Full site visual redesign, site-wide contextual help system, dashboard
KPI overhaul (rolling trailing-month window), and assorted fixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 15:27:39 +02:00
12 changed files with 129 additions and 38 deletions
+14
View File
@@ -7,6 +7,20 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
## [1.5.1] - 2026-08-06
### 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.
- Scheduled emails/WhatsApp messages showed no content when viewing or editing them in "Manage scheduled", even though the message existed in storage: the list endpoint never returned the message body (`html`/`text` for email, `message` for WhatsApp), and editing a scheduled WhatsApp message saved to the wrong payload field (`text` instead of `message`), so edits were silently lost. Both are now fixed, and the email list also shows a body preview like the WhatsApp one already did.
### 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 ### Added
- Site redesign, phase 3: the fixed indigo/purple brand palette is now applied consistently across the whole site — every remaining page that still used the old ad-hoc indigo/blue colors (home, events, registration flow, auth pages, legal pages, payment result pages, and every Admin/Supervisor/Staff/User tool page) now uses the same design tokens as the redesigned dashboards. Deliberate exceptions (status badges like "Confirmed", multi-option selectors like Email/WhatsApp/Both, the WhatsApp role badge) were left alone since they're meaningfully distinct colors, not leftover brand color. - Site redesign, phase 3: the fixed indigo/purple brand palette is now applied consistently across the whole site — every remaining page that still used the old ad-hoc indigo/blue colors (home, events, registration flow, auth pages, legal pages, payment result pages, and every Admin/Supervisor/Staff/User tool page) now uses the same design tokens as the redesigned dashboards. Deliberate exceptions (status badges like "Confirmed", multi-option selectors like Email/WhatsApp/Both, the WhatsApp role badge) were left alone since they're meaningfully distinct colors, not leftover brand color.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "event-management-backend", "name": "event-management-backend",
"version": "1.4.2", "version": "1.5.1",
"description": "Event Management System Backend", "description": "Event Management System Backend",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
@@ -57,6 +57,23 @@ function parseFreeformEmails(lines) {
return recipients; 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 // @desc Preview broadcast recipients and sample
// @route POST /api/broadcasts/preview // @route POST /api/broadcasts/preview
// @access Private/Supervisor or Admin // @access Private/Supervisor or Admin
@@ -191,11 +208,14 @@ const scheduleBroadcast = async (req, res) => {
const payload = { subject, html, text, userIds, emails, eventId }; const payload = { subject, html, text, userIds, emails, eventId };
const recipientSummary = await describeBroadcastRecipients({ userIds, emails: parseFreeformEmails(emails) });
const { addJob } = require('../utils/scheduledEmails'); const { addJob } = require('../utils/scheduledEmails');
const created = addJob({ const created = addJob({
broadcast: true, broadcast: true,
scheduledAt: when.toISOString(), scheduledAt: when.toISOString(),
createdById: req.user?.id || null, createdById: req.user?.id || null,
recipientSummary,
payload, payload,
}); });
@@ -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 // @desc Schedule email to attendees at a specific date/time
// @route POST /api/events/:id/email-attendees/schedule // @route POST /api/events/:id/email-attendees/schedule
// @access Private/Supervisor or Admin // @access Private/Supervisor or Admin
@@ -1600,6 +1609,7 @@ const scheduleEmailEventAttendees = async (req, res) => {
eventId, eventId,
createdById: req.user?.id || null, createdById: req.user?.id || null,
scheduledAt: when.toISOString(), scheduledAt: when.toISOString(),
recipientSummary: `${event.title}${describeAttendeeFilter(filter)}`,
payload, payload,
}); });
@@ -1637,6 +1647,7 @@ const scheduleWhatsappEventAttendees = async (req, res) => {
channel: 'whatsapp', channel: 'whatsapp',
createdById: req.user?.id || null, createdById: req.user?.id || null,
scheduledAt: when.toISOString(), scheduledAt: when.toISOString(),
recipientSummary: `${event.title}${describeAttendeeFilter(filter)}`,
payload, payload,
}); });
@@ -6,11 +6,14 @@ function toClient(job) {
const subject = job?.payload?.subject || ''; const subject = job?.payload?.subject || '';
const html = job?.payload?.html || ''; const html = job?.payload?.html || '';
const text = job?.payload?.text || ''; const text = job?.payload?.text || '';
const message = job?.payload?.message || '';
return { return {
id: job.id, id: job.id,
kind, kind,
eventId: job.eventId || null, eventId: job.eventId || null,
broadcast: !!job.broadcast, broadcast: !!job.broadcast,
channel: job.channel || 'email',
recipient: job.recipientSummary || null,
scheduledAt: job.scheduledAt, scheduledAt: job.scheduledAt,
createdAt: job.createdAt, createdAt: job.createdAt,
status: job.status, status: job.status,
@@ -18,26 +21,21 @@ function toClient(job) {
sentAt: job.sentAt || null, sentAt: job.sentAt || null,
lastError: job.lastError || null, lastError: job.lastError || null,
subject, subject,
html,
text,
message,
hasHtml: !!html, hasHtml: !!html,
hasText: !!text, hasText: !!text,
}; };
} }
// GET /api/scheduled-emails // GET /api/scheduled-emails?channel=email|whatsapp
// Returns jobs excluding emails sent more than a week ago // Sent jobs are purged from storage 24h after sending, so nothing older than that is ever returned here
const listScheduledEmails = async (req, res) => { const listScheduledEmails = async (req, res) => {
try { try {
const raw = listJobs(); const { channel } = req.query || {};
const now = new Date(); const raw = listJobs(channel ? { channel: String(channel) } : {});
const weekMs = 7 * 24 * 60 * 60 * 1000; const filtered = raw
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;
})
// Provide most-relevant first: queued -> sending -> error -> recent sent // Provide most-relevant first: queued -> sending -> error -> recent sent
.sort((a, b) => { .sort((a, b) => {
const order = { queued: 0, sending: 1, error: 2, sent: 3 }; const order = { queued: 0, sending: 1, error: 2, sent: 3 };
@@ -56,7 +54,7 @@ const listScheduledEmails = async (req, res) => {
}; };
// PATCH /api/scheduled-emails/:id // PATCH /api/scheduled-emails/:id
// Allows editing scheduledAt, subject, html/text on queued jobs only // Allows editing scheduledAt and message content (subject/html/text for email jobs, message for WhatsApp jobs) on queued jobs only
const updateScheduledEmail = async (req, res) => { const updateScheduledEmail = async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
@@ -64,7 +62,7 @@ const updateScheduledEmail = async (req, res) => {
if (!job) return res.status(404).json({ message: 'Job not found' }); if (!job) return res.status(404).json({ message: 'Job not found' });
if (job.status !== 'queued') return res.status(400).json({ message: 'Only queued jobs can be edited' }); if (job.status !== 'queued') return res.status(400).json({ message: 'Only queued jobs can be edited' });
const { scheduledAt, subject, html, text } = req.body || {}; const { scheduledAt, subject, html, text, message } = req.body || {};
const patch = {}; const patch = {};
if (scheduledAt) { if (scheduledAt) {
@@ -72,14 +70,12 @@ const updateScheduledEmail = async (req, res) => {
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' }); if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
patch.scheduledAt = when.toISOString(); patch.scheduledAt = when.toISOString();
} }
if (subject != null || html != null || text != null) { if (subject != null || html != null || text != null || message != null) {
const payload = { ...(job.payload || {}) }; const payload = { ...(job.payload || {}) };
if (subject != null) payload.subject = subject; if (subject != null) payload.subject = subject;
if (html != null || text != null) { if (html != null) payload.html = html;
// If html provided explicitly, set html; if text provided, set text if (text != null) payload.text = text;
if (html != null) payload.html = html; if (message != null) payload.message = message;
if (text != null) payload.text = text;
}
patch.payload = payload; patch.payload = payload;
} }
@@ -36,6 +36,22 @@ function parseFreeformPhones(lines) {
return recipients; 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 // @desc Preview WhatsApp broadcast recipients
// @route POST /api/whatsapp-broadcasts/preview // @route POST /api/whatsapp-broadcasts/preview
// @access Private/Supervisor or Admin // @access Private/Supervisor or Admin
@@ -152,12 +168,15 @@ const scheduleWhatsAppBroadcast = async (req, res) => {
const payload = { message, userIds, phones, eventId }; const payload = { message, userIds, phones, eventId };
const recipientSummary = await describeBroadcastRecipients({ userIds, phones });
const { addJob } = require('../utils/scheduledEmails'); const { addJob } = require('../utils/scheduledEmails');
const created = addJob({ const created = addJob({
broadcast: true, broadcast: true,
channel: 'whatsapp', channel: 'whatsapp',
scheduledAt: when.toISOString(), scheduledAt: when.toISOString(),
createdById: req.user?.id || null, createdById: req.user?.id || null,
recipientSummary,
payload, payload,
}); });
+2 -1
View File
@@ -1192,13 +1192,14 @@ app.listen(PORT, () => {
try { try {
const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false'; const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) { if (enabled) {
const { getDueJobs, updateJob } = require('./utils/scheduledEmails'); const { getDueJobs, updateJob, purgeSentJobs } = require('./utils/scheduledEmails');
const { emailEventAttendees, whatsappEventAttendees } = require('./controllers/eventController'); const { emailEventAttendees, whatsappEventAttendees } = require('./controllers/eventController');
const { sendBroadcast } = require('./controllers/broadcastController'); const { sendBroadcast } = require('./controllers/broadcastController');
const { sendWhatsAppBroadcast } = require('./controllers/whatsappBroadcastController'); const { sendWhatsAppBroadcast } = require('./controllers/whatsappBroadcastController');
const intervalMs = parseInt(process.env.SCHEDULED_EMAILS_INTERVAL_MS || '30000', 10); const intervalMs = parseInt(process.env.SCHEDULED_EMAILS_INTERVAL_MS || '30000', 10);
setInterval(async () => { setInterval(async () => {
try { try {
try { purgeSentJobs(24 * 60 * 60 * 1000); } catch (e) { console.warn('[scheduled emails] purge failed:', e?.message || e); }
const due = getDueJobs(new Date()); const due = getDueJobs(new Date());
if (!due || due.length === 0) return; if (!due || due.length === 0) return;
for (const job of due) { for (const job of due) {
+19
View File
@@ -46,6 +46,8 @@ function addJob(job) {
id, id,
eventId: job.eventId || null, eventId: job.eventId || null,
broadcast: !!job.broadcast, broadcast: !!job.broadcast,
channel: job.channel || 'email',
recipientSummary: job.recipientSummary || null,
createdById: job.createdById || null, createdById: job.createdById || null,
scheduledAt: job.scheduledAt, scheduledAt: job.scheduledAt,
createdAt: now.toISOString(), createdAt: now.toISOString(),
@@ -66,10 +68,26 @@ function listJobs(filter = {}) {
return jobs.filter(j => { return jobs.filter(j => {
if (filter.status && j.status !== filter.status) return false; if (filter.status && j.status !== filter.status) return false;
if (filter.eventId && j.eventId !== filter.eventId) return false; if (filter.eventId && j.eventId !== filter.eventId) return false;
if (filter.channel && (j.channel || 'email') !== filter.channel) return false;
return true; 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()) { function getDueJobs(now = new Date()) {
const jobs = loadAll(); const jobs = loadAll();
const t = now instanceof Date ? now : new Date(now); const t = now instanceof Date ? now : new Date(now);
@@ -105,4 +123,5 @@ module.exports = {
updateJob, updateJob,
getJob, getJob,
deleteJob, deleteJob,
purgeSentJobs,
}; };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hope-events-frontend", "name": "hope-events-frontend",
"version": "1.4.2", "version": "1.5.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
@@ -261,7 +261,7 @@ function EmailAttendeesPageInner() {
}; };
// Scheduled jobs state // 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; html?: string; text?: string; hasHtml?: boolean; hasText?: boolean };
const [scheduled, setScheduled] = useState<ScheduledJob[]>([]); const [scheduled, setScheduled] = useState<ScheduledJob[]>([]);
const [loadingScheduled, setLoadingScheduled] = useState(false); const [loadingScheduled, setLoadingScheduled] = useState(false);
const [editing, setEditing] = useState<ScheduledJob | null>(null); const [editing, setEditing] = useState<ScheduledJob | null>(null);
@@ -274,7 +274,7 @@ function EmailAttendeesPageInner() {
try { try {
if (!token) return; if (!token) return;
setLoadingScheduled(true); 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 : []); setScheduled(Array.isArray(res?.jobs) ? res.jobs : []);
} catch (e) { } catch (e) {
// ignore here; surfaces via UI when tab open // ignore here; surfaces via UI when tab open
@@ -288,7 +288,7 @@ function EmailAttendeesPageInner() {
const openEdit = (job: ScheduledJob) => { const openEdit = (job: ScheduledJob) => {
setEditing(job); setEditing(job);
setEditSubject(job.subject || ''); setEditSubject(job.subject || '');
setEditBody(''); // body not included in list; will let user set a new one if needed setEditBody(job.html || job.text || '');
try { setEditWhen(toLocalInputValue(new Date(job.scheduledAt))); } catch { setEditWhen(''); } try { setEditWhen(toLocalInputValue(new Date(job.scheduledAt))); } catch { setEditWhen(''); }
}; };
@@ -872,7 +872,7 @@ Jane Doe <jane@example.com>
{loadingScheduled ? ( {loadingScheduled ? (
<div className="text-sm text-gray-600">Loading</div> <div className="text-sm text-gray-600">Loading</div>
) : scheduled.length === 0 ? ( ) : scheduled.length === 0 ? (
<div className="text-sm text-gray-600">No scheduled items. Items sent more than a week ago are hidden.</div> <div className="text-sm text-gray-600">No scheduled emails. Sent items are cleared 24 hours after sending.</div>
) : ( ) : (
<ul className="divide-y border rounded"> <ul className="divide-y border rounded">
{scheduled.map(job => ( {scheduled.map(job => (
@@ -882,6 +882,16 @@ Jane Doe <jane@example.com>
<span className="inline-block px-2 py-0.5 text-xs rounded border bg-gray-50">{job.kind}</span> <span className="inline-block px-2 py-0.5 text-xs rounded border bg-gray-50">{job.kind}</span>
<span className="truncate">{job.subject || '(no subject)'}</span> <span className="truncate">{job.subject || '(no subject)'}</span>
</div> </div>
<div className="text-xs text-gray-600 mt-1 truncate">
{(() => {
const body = (job.html || job.text || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
if (!body) return '(no content)';
return body.length > 80 ? body.slice(0, 80) + '…' : body;
})()}
</div>
<div className="text-xs text-gray-600 mt-1">
<span className="mr-2">To: {job.recipient || 'Unknown recipients'}</span>
</div>
<div className="text-xs text-gray-600 mt-1"> <div className="text-xs text-gray-600 mt-1">
<span className="mr-2">Status: {job.status}</span> <span className="mr-2">Status: {job.status}</span>
<span className="mr-2">Scheduled: {(() => { try { return new Date(job.scheduledAt).toLocaleString(); } catch { return job.scheduledAt; } })()}</span> <span className="mr-2">Scheduled: {(() => { try { return new Date(job.scheduledAt).toLocaleString(); } catch { return job.scheduledAt; } })()}</span>
@@ -535,7 +535,7 @@ function WhatsAppAttendeesPageInner() {
}; };
// ── Scheduled tab ────────────────────────────────────────────────────────── // ── 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; message?: string };
const [scheduled, setScheduled] = useState<ScheduledJob[]>([]); const [scheduled, setScheduled] = useState<ScheduledJob[]>([]);
const [loadingScheduled, setLoadingScheduled] = useState(false); const [loadingScheduled, setLoadingScheduled] = useState(false);
const [editing, setEditing] = useState<ScheduledJob | null>(null); const [editing, setEditing] = useState<ScheduledJob | null>(null);
@@ -547,10 +547,8 @@ function WhatsAppAttendeesPageInner() {
try { try {
if (!token) return; if (!token) return;
setLoadingScheduled(true); setLoadingScheduled(true);
const res = await apiFetch<{ jobs: ScheduledJob[] }>(`/api/scheduled-emails`, { authToken: token }); const res = await apiFetch<{ jobs: ScheduledJob[] }>(`/api/scheduled-emails?channel=whatsapp`, { authToken: token });
// Filter to only WhatsApp jobs setScheduled(Array.isArray(res?.jobs) ? res.jobs : []);
const all = Array.isArray(res?.jobs) ? res.jobs : [];
setScheduled(all.filter((j) => j.channel === "whatsapp" || (j.broadcast && j.channel === "whatsapp")));
} catch { } finally { setLoadingScheduled(false); } } catch { } finally { setLoadingScheduled(false); }
}; };
@@ -563,7 +561,7 @@ function WhatsAppAttendeesPageInner() {
if (!token) { setError("Not authenticated"); return; } if (!token) { setError("Not authenticated"); return; }
const body: any = {}; const body: any = {};
if (editWhen) body.scheduledAt = new Date(editWhen).toISOString(); if (editWhen) body.scheduledAt = new Date(editWhen).toISOString();
if (editMessage.trim()) body.text = editMessage; if (editMessage.trim()) body.message = editMessage;
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(editing.id)}`, { method: "PATCH", authToken: token, body }); await apiFetch(`/api/scheduled-emails/${encodeURIComponent(editing.id)}`, { method: "PATCH", authToken: token, body });
setInfo("Scheduled message updated."); setInfo("Scheduled message updated.");
setEditing(null); setEditing(null);
@@ -882,7 +880,7 @@ function WhatsAppAttendeesPageInner() {
{loadingScheduled ? ( {loadingScheduled ? (
<div className="text-sm text-gray-600">Loading</div> <div className="text-sm text-gray-600">Loading</div>
) : scheduled.length === 0 ? ( ) : scheduled.length === 0 ? (
<div className="text-sm text-gray-600">No scheduled WhatsApp messages. Items sent more than a week ago are hidden.</div> <div className="text-sm text-gray-600">No scheduled WhatsApp messages. Sent items are cleared 24 hours after sending.</div>
) : ( ) : (
<ul className="divide-y border rounded"> <ul className="divide-y border rounded">
{scheduled.map((job) => ( {scheduled.map((job) => (
@@ -890,7 +888,10 @@ function WhatsAppAttendeesPageInner() {
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-medium text-gray-900 flex items-center gap-2"> <div className="text-sm font-medium text-gray-900 flex items-center gap-2">
<span className="inline-block px-2 py-0.5 text-xs rounded border bg-green-50 text-green-700">{job.broadcast ? "broadcast" : "attendees"}</span> <span className="inline-block px-2 py-0.5 text-xs rounded border bg-green-50 text-green-700">{job.broadcast ? "broadcast" : "attendees"}</span>
<span className="truncate text-gray-700">{job.payload?.message ? String(job.payload.message).slice(0, 60) + (String(job.payload.message).length > 60 ? "…" : "") : "(no message)"}</span> <span className="truncate text-gray-700">{job.message ? String(job.message).slice(0, 60) + (String(job.message).length > 60 ? "…" : "") : "(no message)"}</span>
</div>
<div className="text-xs text-gray-600 mt-1">
<span className="mr-2">To: {job.recipient || "Unknown recipients"}</span>
</div> </div>
<div className="text-xs text-gray-600 mt-1"> <div className="text-xs text-gray-600 mt-1">
<span className="mr-2">Status: {job.status}</span> <span className="mr-2">Status: {job.status}</span>
@@ -901,7 +902,7 @@ function WhatsAppAttendeesPageInner() {
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<button type="button" disabled={job.status !== "queued"} className="px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50" <button type="button" disabled={job.status !== "queued"} className="px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"
onClick={() => { setEditing(job); setEditMessage(job.payload?.message || ""); try { setEditWhen(toLocalInputValue(new Date(job.scheduledAt))); } catch { setEditWhen(""); } }}> onClick={() => { setEditing(job); setEditMessage(job.message || ""); try { setEditWhen(toLocalInputValue(new Date(job.scheduledAt))); } catch { setEditWhen(""); } }}>
Edit Edit
</button> </button>
<button type="button" disabled={job.status !== "queued"} className="px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50" onClick={() => removeJob(job)}> <button type="button" disabled={job.status !== "queued"} className="px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50" onClick={() => removeJob(job)}>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hope-events", "name": "hope-events",
"version": "1.4.2", "version": "1.5.1",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev:backend": "cd backend && npm run dev", "dev:backend": "cd backend && npm run dev",