Files
hope-events/backend/src/controllers/scheduledEmailsController.js
T
joshuaandClaude Opus 5.5 344f260011 Campus-scoped staff: schema, campusScope helper, route test, first 79 routes
UserCampusScope relation and audit action; scopeEvent/scopeList/unscopedOnly/
campusIndependent/scopeBroadcast markers; tests/campusScopeRoutes.test.js
(still failing: events, payments, registrations, scan locks, tickets left).
Can't delete a campus that has scoped staff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:28:30 +02:00

122 lines
4.6 KiB
JavaScript

const { listJobs, getJob, updateJob, deleteJob } = require('../utils/scheduledEmails');
const prisma = require('../config/db');
const { scopeFor, eventWhere } = require('../utils/campusScope');
/**
* The jobs a campus-scoped supervisor may see and change: messages for events they can see, and
* broadcasts they scheduled themselves. Everyone else sees every job.
*/
async function visibleJobs(req, jobs) {
const scope = await scopeFor(req);
if (!scope) return jobs;
const ids = [...new Set(jobs.map(j => j.eventId).filter(Boolean))];
const seen = ids.length
? new Set((await prisma.event.findMany({ where: { id: { in: ids }, ...eventWhere(scope) }, select: { id: true } })).map(e => e.id))
: new Set();
return jobs.filter(j => (j.eventId ? seen.has(j.eventId) : j.createdById === req.user.id));
}
// Normalize job for client UI
function toClient(job) {
const kind = job.broadcast ? 'broadcast' : (job.eventId ? 'attendees' : 'unknown');
const subject = job?.payload?.subject || '';
const html = job?.payload?.html || '';
const text = job?.payload?.text || '';
const message = job?.payload?.message || '';
return {
id: job.id,
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,
attempts: job.attempts || 0,
sentAt: job.sentAt || null,
lastError: job.lastError || null,
subject,
html,
text,
message,
hasHtml: !!html,
hasText: !!text,
};
}
// 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 { channel } = req.query || {};
const raw = await visibleJobs(req, 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 };
const oa = order[a.status] ?? 99;
const ob = order[b.status] ?? 99;
if (oa !== ob) return oa - ob;
// Then by scheduledAt asc
return new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime();
})
.map(toClient);
return res.json({ jobs: filtered });
} catch (e) {
return res.status(400).json({ message: e?.message || 'Failed to list scheduled emails' });
}
};
// PATCH /api/scheduled-emails/:id
// 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) => {
try {
const { id } = req.params;
const job = getJob(id);
if (!job || !(await visibleJobs(req, [job])).length) 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' });
const { scheduledAt, subject, html, text, message } = req.body || {};
const patch = {};
if (scheduledAt) {
const when = new Date(scheduledAt);
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
patch.scheduledAt = when.toISOString();
}
if (subject != null || html != null || text != null || message != null) {
const payload = { ...(job.payload || {}) };
if (subject != null) payload.subject = subject;
if (html != null) payload.html = html;
if (text != null) payload.text = text;
if (message != null) payload.message = message;
patch.payload = payload;
}
const updated = updateJob(id, patch);
return res.json({ message: 'Updated', job: toClient(updated) });
} catch (e) {
return res.status(400).json({ message: e?.message || 'Failed to update job' });
}
};
// DELETE /api/scheduled-emails/:id
// Only queued jobs can be removed
const deleteScheduledEmail = async (req, res) => {
try {
const { id } = req.params;
const job = getJob(id);
if (!job || !(await visibleJobs(req, [job])).length) return res.status(404).json({ message: 'Job not found' });
if (job.status !== 'queued') return res.status(400).json({ message: 'Only queued jobs can be deleted' });
const ok = deleteJob(id);
if (!ok) return res.status(404).json({ message: 'Job not found' });
return res.json({ message: 'Deleted' });
} catch (e) {
return res.status(400).json({ message: e?.message || 'Failed to delete job' });
}
};
module.exports = { listScheduledEmails, updateScheduledEmail, deleteScheduledEmail };