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>
122 lines
4.6 KiB
JavaScript
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 };
|