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 };