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>
194 lines
9.2 KiB
JavaScript
194 lines
9.2 KiB
JavaScript
const prisma = require('../config/db');
|
|
const { safeErrorMessage } = require('../utils/errorUtils');
|
|
const { getClientIp } = require('../utils/requestUtils');
|
|
const { logAdminAction } = require('../utils/adminAudit');
|
|
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
|
|
const { SETTING_KEY, CAMPUS_PUBLIC_SELECT, campusesEnabled, cleanCampusInput, campusAudienceWhere } = require('../utils/campuses');
|
|
|
|
const audit = (req, action, campus, metadata) => logAdminAction({
|
|
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'Campus', targetId: campus?.id, metadata: { name: campus?.name, ...metadata }, ip: getClientIp(req),
|
|
});
|
|
|
|
// A slug that's already taken → a message instead of a Prisma error.
|
|
function friendlyError(e) {
|
|
if (e?.code === 'P2002') return 'Another campus already uses that web address — choose a different one.';
|
|
return e.message || safeErrorMessage(e);
|
|
}
|
|
|
|
const ADMIN_INCLUDE = {
|
|
notifyRecipients: { select: { id: true, name: true, email: true } },
|
|
_count: { select: { events: true, homeOf: true, scopedStaff: true } },
|
|
};
|
|
|
|
// @desc Whether campuses are on, and the active campuses (public site filters, event cards)
|
|
// @route GET /api/campuses
|
|
// @access Public
|
|
const getCampuses = async (req, res) => {
|
|
try {
|
|
const enabled = await campusesEnabled();
|
|
const campuses = enabled
|
|
? await prisma.campus.findMany({ where: { archivedAt: null }, select: CAMPUS_PUBLIC_SELECT, orderBy: [{ order: 'asc' }, { name: 'asc' }] })
|
|
: [];
|
|
res.json({ enabled, campuses });
|
|
} catch (e) {
|
|
res.status(500).json({ message: safeErrorMessage(e) });
|
|
}
|
|
};
|
|
|
|
// @desc A campus landing page: the campus (active only)
|
|
// @route GET /api/campuses/by-slug/:slug
|
|
// @access Public
|
|
const getCampusBySlug = async (req, res) => {
|
|
try {
|
|
const campus = (await campusesEnabled())
|
|
? await prisma.campus.findFirst({ where: { slug: String(req.params.slug), archivedAt: null }, select: CAMPUS_PUBLIC_SELECT })
|
|
: null;
|
|
if (!campus) return res.status(404).json({ message: 'Campus not found' });
|
|
res.json(campus);
|
|
} catch (e) {
|
|
res.status(500).json({ message: safeErrorMessage(e) });
|
|
}
|
|
};
|
|
|
|
// @desc All campuses with archived ones, event counts and summary recipients (Site settings → Campuses)
|
|
// @route GET /api/campuses/admin
|
|
// @access Private/Admin
|
|
const getCampusesAdmin = async (req, res) => {
|
|
try {
|
|
const [enabled, campuses] = await Promise.all([
|
|
campusesEnabled(),
|
|
prisma.campus.findMany({ include: ADMIN_INCLUDE, orderBy: [{ archivedAt: { sort: 'asc', nulls: 'first' } }, { order: 'asc' }, { name: 'asc' }] }),
|
|
]);
|
|
res.json({ enabled, campuses });
|
|
} catch (e) {
|
|
res.status(500).json({ message: safeErrorMessage(e) });
|
|
}
|
|
};
|
|
|
|
// @desc Turn campuses on or off (off hides campus fields and filters; nothing is deleted)
|
|
// @route PUT /api/campuses/settings body: { enabled }
|
|
// @access Private/Admin
|
|
const updateCampusSettings = async (req, res) => {
|
|
try {
|
|
const enabled = req.body?.enabled === true;
|
|
await prisma.appSetting.upsert({ where: { key: SETTING_KEY }, update: { value: String(enabled) }, create: { key: SETTING_KEY, value: String(enabled) } });
|
|
invalidateSettingsCache();
|
|
await warmCache();
|
|
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'campuses_toggled', targetType: 'AppSetting', metadata: { enabled }, ip: getClientIp(req) });
|
|
res.json({ enabled });
|
|
} catch (e) {
|
|
res.status(500).json({ message: safeErrorMessage(e) });
|
|
}
|
|
};
|
|
|
|
// @desc Add a campus (added at the end of the list)
|
|
// @route POST /api/campuses
|
|
// @access Private/Admin
|
|
const createCampus = async (req, res) => {
|
|
try {
|
|
const { data, error } = cleanCampusInput(req.body);
|
|
if (error) return res.status(400).json({ message: error });
|
|
const last = await prisma.campus.aggregate({ _max: { order: true } });
|
|
const campus = await prisma.campus.create({ data: { ...data, order: (last._max.order ?? -1) + 1 }, include: ADMIN_INCLUDE });
|
|
audit(req, 'campus_created', campus, { slug: campus.slug });
|
|
res.status(201).json(campus);
|
|
} catch (e) {
|
|
res.status(e?.code === 'P2002' ? 409 : 400).json({ message: friendlyError(e) });
|
|
}
|
|
};
|
|
|
|
// @desc Change a campus: details, summary recipients, archived or not
|
|
// @route PUT /api/campuses/:id body: campus fields, notifyRecipientIds?, archived?
|
|
// @access Private/Admin
|
|
const updateCampus = async (req, res) => {
|
|
try {
|
|
const existing = await prisma.campus.findUnique({ where: { id: req.params.id } });
|
|
if (!existing) return res.status(404).json({ message: 'Campus not found' });
|
|
const { data, error } = cleanCampusInput(req.body, { partial: true });
|
|
if (error) return res.status(400).json({ message: error });
|
|
|
|
if (Array.isArray(req.body?.notifyRecipientIds)) {
|
|
const ids = req.body.notifyRecipientIds.filter(x => typeof x === 'string' && x);
|
|
// Summaries go to people who run events — never to ordinary users.
|
|
const staff = await prisma.user.findMany({ where: { id: { in: ids }, role: { in: ['admin', 'supervisor', 'staff'] } }, select: { id: true } });
|
|
data.notifyRecipients = { set: staff.map(u => ({ id: u.id })) };
|
|
}
|
|
let archiveChange = null;
|
|
if (req.body?.archived !== undefined) {
|
|
const archived = req.body.archived === true;
|
|
if (archived !== !!existing.archivedAt) {
|
|
data.archivedAt = archived ? new Date() : null;
|
|
archiveChange = archived;
|
|
}
|
|
}
|
|
|
|
const campus = await prisma.campus.update({ where: { id: existing.id }, data, include: ADMIN_INCLUDE });
|
|
if (archiveChange !== null) audit(req, 'campus_archived', campus, { archived: archiveChange });
|
|
const changed = Object.keys(data).filter(k => k !== 'archivedAt');
|
|
if (changed.length) audit(req, 'campus_updated', campus, { changed });
|
|
res.json(campus);
|
|
} catch (e) {
|
|
res.status(e?.code === 'P2002' ? 409 : 400).json({ message: friendlyError(e) });
|
|
}
|
|
};
|
|
|
|
// @desc Put the campuses in this order (filters and lists follow it)
|
|
// @route PUT /api/campuses/order body: { ids: [...] }
|
|
// @access Private/Admin
|
|
const reorderCampuses = async (req, res) => {
|
|
try {
|
|
const ids = Array.isArray(req.body?.ids) ? req.body.ids.filter(x => typeof x === 'string') : [];
|
|
if (!ids.length) return res.status(400).json({ message: 'Send the campus ids in their new order.' });
|
|
await prisma.$transaction(ids.map((id, order) => prisma.campus.updateMany({ where: { id }, data: { order } })));
|
|
res.json({ ok: true });
|
|
} catch (e) {
|
|
res.status(400).json({ message: safeErrorMessage(e) });
|
|
}
|
|
};
|
|
|
|
// @desc Delete a campus that has no events (otherwise archive it). Home campus is cleared.
|
|
// @route DELETE /api/campuses/:id
|
|
// @access Private/Admin
|
|
const deleteCampus = async (req, res) => {
|
|
try {
|
|
const campus = await prisma.campus.findUnique({ where: { id: req.params.id }, include: { _count: { select: { events: true, scopedStaff: true } } } });
|
|
if (!campus) return res.status(404).json({ message: 'Campus not found' });
|
|
// Deleting would drop their limit and leave them unscoped (seeing every campus).
|
|
if (campus._count.scopedStaff > 0) {
|
|
return res.status(409).json({ code: 'CAMPUS_HAS_SCOPED_STAFF', message: `${campus._count.scopedStaff} staff member${campus._count.scopedStaff === 1 ? ' is' : 's are'} limited to ${campus.name}. Change their campuses in Admin → Users first, or archive the campus.` });
|
|
}
|
|
if (campus._count.events > 0) {
|
|
return res.status(409).json({ code: 'CAMPUS_HAS_EVENTS', message: `${campus.name} has ${campus._count.events} event${campus._count.events === 1 ? '' : 's'}, so it can't be deleted. Archive it instead.` });
|
|
}
|
|
await prisma.campus.delete({ where: { id: campus.id } });
|
|
audit(req, 'campus_deleted', campus, { slug: campus.slug });
|
|
res.json({ message: 'Campus deleted' });
|
|
} catch (e) {
|
|
res.status(400).json({ message: safeErrorMessage(e) });
|
|
}
|
|
};
|
|
|
|
// @desc "Everyone at campus X" for the email/WhatsApp composers: home campus X, plus (with
|
|
// ?registrants=true) people registered for X's events in the last 12 months. The
|
|
// composer sends to these ids as usual, so news consent is still checked on sending.
|
|
// @route GET /api/campuses/:id/audience?registrants=true
|
|
// @access Private/Supervisor
|
|
const getCampusAudience = async (req, res) => {
|
|
try {
|
|
const campus = await prisma.campus.findUnique({ where: { id: req.params.id }, select: { id: true, name: true } });
|
|
if (!campus) return res.status(404).json({ message: 'Campus not found' });
|
|
const scope = await require('../utils/campusScope').scopeFor(req);
|
|
if (scope && !scope.campusIds.includes(campus.id)) return res.status(404).json({ message: 'Campus not found' });
|
|
const users = await prisma.user.findMany({
|
|
where: campusAudienceWhere(campus.id, { includeRegistrants: req.query.registrants === 'true' }),
|
|
select: { id: true, name: true, email: true, phoneNumber: true },
|
|
orderBy: { name: 'asc' },
|
|
});
|
|
res.json({ campus, users });
|
|
} catch (e) {
|
|
res.status(500).json({ message: safeErrorMessage(e) });
|
|
}
|
|
};
|
|
|
|
module.exports = { getCampusAudience, getCampuses, getCampusBySlug, getCampusesAdmin, updateCampusSettings, createCampus, updateCampus, reorderCampuses, deleteCampus };
|