Files
hope-events/backend/src/utils/campusScope.js
T
joshuaandClaude Opus 5.5 39885a8719 Volunteer rosters: roles, sign-ups, swaps, QR check-in, kids-room duty (backend)
Serving roles per event or session, sign-ups and assigning with an overlap
check, swaps that happen on accept (notify recipients emailed), QR and manual
check-in (kids-room roles start rostered duty), optional free volunteer ticket,
day-before reminder job, hours served, privacy hooks. The kids check-in and
volunteer tickets are left out of public event pages and public registration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 03:59:52 +02:00

291 lines
14 KiB
JavaScript

/**
* Campus-scoped staff (1.44.0). A supervisor or staff member can be limited to some campuses
* (Admin → Users → "Limit to campuses"). They then see their campuses' events plus events for
* all campuses (Event.campusId null) — and can work on those church-wide events (view, scan,
* door payments, manual registrations) but not set them up, cash them up or refund them.
*
* Unscoped (no limits) = everything, as before. Admins are never scoped, and scopes are ignored
* while campuses are off (Site settings → Campuses).
*
* How a route is checked — every staff/supervisor route carries one of these markers, and
* tests/campusScopeRoutes.test.js fails on any route that doesn't:
* scopeEvent({ resolve, from, need }) the route works on one event (or a record of one)
* scopeList the controller filters (listWhere) or checks it itself
* unscopedOnly admins and unscoped supervisors only
* campusIndependent('reason') nothing to do with any event's campus
*/
const prisma = require('../config/db');
const { campusesEnabled } = require('./campuses');
const STAFF_ROLES = new Set(['supervisor', 'staff']);
/**
* The scope of the signed-in user: null (unscoped) or { campusIds, names }. Worked out once
* per request.
*/
async function scopeFor(req) {
if (req._campusScope !== undefined) return req._campusScope;
let scope = null;
if (req.user && STAFF_ROLES.has(req.user.role) && await campusesEnabled()) {
const row = await prisma.user.findUnique({ where: { id: req.user.id }, select: { scopedCampuses: { select: { id: true, name: true } } } });
const campuses = row?.scopedCampuses || [];
if (campuses.length) scope = { campusIds: campuses.map(c => c.id), names: campuses.map(c => c.name) };
}
req._campusScope = scope;
return scope;
}
/** The `where` on events for a scope: its campuses plus church-wide events. {} when unscoped. */
function eventWhere(scope) {
if (!scope) return {};
return { OR: [{ campusId: { in: scope.campusIds } }, { campusId: null }] };
}
/** The `where` on events a scope may manage: only its campuses' (not church-wide). {} when unscoped. */
function manageWhere(scope) {
if (!scope) return {};
return { campusId: { in: scope.campusIds } };
}
/**
* The same, through a relation path: relWhere(scope, 'event') → { event: {...} },
* relWhere(scope, 'registration.event') → { registration: { event: {...} } }.
*/
function relWhere(scope, path) {
if (!scope) return {};
return path.split('.').reverse().reduce((inner, key) => ({ [key]: inner }), eventWhere(scope));
}
/** Can this scope see/work on an event at `campusId` (null = all campuses)? */
function canView(scope, campusId) {
return !scope || campusId == null || scope.campusIds.includes(campusId);
}
/** Can this scope set up, cash up or refund an event at `campusId`? Not church-wide ones. */
function canManage(scope, campusId) {
return !scope || (campusId != null && scope.campusIds.includes(campusId));
}
// id → { id, campusId, campusName } of the event a record belongs to, or null if there's no such
// record. The event select is shared so every resolver answers the same shape.
// `owners` are the users the record belongs to, for scopeEvent({ ownerOk }).
const EV = { select: { id: true, campusId: true, campus: { select: { name: true } } } };
const shape = (ev, owners = []) => (ev ? { id: ev.id, campusId: ev.campusId, campusName: ev.campus?.name || null, owners: owners.filter(Boolean) } : null);
const RESOLVERS = {
event: async id => shape(await prisma.event.findUnique({ where: { id }, ...EV })),
registration: async id => {
const r = await prisma.registration.findUnique({ where: { id }, select: { userId: true, event: EV } });
return shape(r?.event, [r?.userId]);
},
ticket: async id => {
const t = await prisma.ticket.findUnique({ where: { id }, select: { userId: true, holderUserId: true, event: EV } });
return shape(t?.event, [t?.userId, t?.holderUserId]);
},
ticketQr: async qrCode => shape((await prisma.ticket.findUnique({ where: { qrCode }, select: { event: EV } }))?.event),
payment: async id => {
const p = await prisma.payment.findUnique({ where: { id }, select: { userId: true, event: EV, registration: { select: { event: EV } } } });
return shape(p?.event || p?.registration?.event, [p?.userId]);
},
eventOption: async id => shape((await prisma.eventOption.findUnique({ where: { id }, select: { event: EV } }))?.event),
optionVariant: async id => shape((await prisma.optionVariant.findUnique({ where: { id }, select: { eventOption: { select: { event: EV } } } }))?.eventOption?.event),
bundle: async id => shape((await prisma.eventBundle.findUnique({ where: { id }, select: { event: EV } }))?.event),
allocationSet: async id => shape((await prisma.allocationSet.findUnique({ where: { id }, select: { event: EV } }))?.event),
allocationGroup: async id => shape((await prisma.allocationGroup.findUnique({ where: { id }, select: { set: { select: { event: EV } } } }))?.set?.event),
waitlistEntry: async id => shape((await prisma.waitlistEntry.findUnique({ where: { id }, select: { event: EV } }))?.event),
scanLock: async id => shape((await prisma.scanLock.findUnique({ where: { id }, select: { event: EV } }))?.event),
syncReport: async id => shape((await prisma.offlineSyncReport.findUnique({ where: { id }, select: { scanLock: { select: { event: EV } } } }))?.scanLock?.event),
section: async id => shape((await prisma.section.findUnique({ where: { id }, select: { event: EV } }))?.event),
certificate: async id => shape((await prisma.certificate.findUnique({ where: { id }, select: { event: EV } }))?.event),
consentRequest: async id => {
const c = await prisma.consentRequest.findUnique({ where: { id }, select: { event: EV, attendee: { select: { registration: { select: { userId: true } } } } } });
return shape(c?.event, [c?.attendee?.registration?.userId]);
},
kidsCheckIn: async id => shape((await prisma.kidsCheckIn.findUnique({ where: { id }, select: { event: EV } }))?.event),
kidsAlert: async id => shape((await prisma.kidsAlert.findUnique({ where: { id }, select: { event: EV } }))?.event),
servingRole: async id => shape((await prisma.servingRole.findUnique({ where: { id }, select: { event: EV } }))?.event),
volunteerSlot: async id => shape((await prisma.volunteerSlot.findUnique({ where: { id }, select: { role: { select: { event: EV } } } }))?.role?.event),
};
/** The event a record belongs to (see RESOLVERS), or null. */
async function eventOf(kind, id) {
const resolve = RESOLVERS[kind];
if (!resolve) throw new Error(`No campus resolver for "${kind}"`);
if (!id) return null;
return resolve(String(id));
}
const NOT_FOUND = { message: 'Not found' };
const CHURCH_WIDE_MSG = 'This is an event for all campuses — only admins and supervisors for every campus can change it.';
/**
* Check a scoped user against one event, found from the request. Out of scope → 404 (another
* campus's records don't leak); a church-wide event when `need: 'manage'` → 403. Records that
* don't exist pass through so the controller answers as it always has.
* resolve which RESOLVERS entry turns the id into its event (default 'event')
* from req → id (default req.params.eventId || req.params.id)
* need 'view' (default) or 'manage'
* ownerOk let the record's own user through (a staff member's own registration for another
* campus's event) — for routes open to the owner as well as to staff
* nameCampus answer 403 naming the event's campus instead of 404 (the ticket scanner: the
* person at the door needs to know where the ticket belongs)
*/
function scopeEvent({ resolve = 'event', from = req => req.params.eventId || req.params.id, need = 'view', ownerOk = false, nameCampus = false } = {}) {
if (!RESOLVERS[resolve]) throw new Error(`No campus resolver for "${resolve}"`);
const mw = async (req, res, next) => {
try {
const scope = await scopeFor(req);
if (!scope) return next();
const ev = await eventOf(resolve, from(req));
if (!ev) return next();
if (ownerOk && ev.owners.includes(req.user.id)) return next();
if (!canView(scope, ev.campusId)) {
if (nameCampus) return res.status(403).json({ code: 'OTHER_CAMPUS', message: `This ticket is for an event at ${ev.campusName || 'another campus'}.` });
return res.status(404).json(NOT_FOUND);
}
if (need === 'manage' && !canManage(scope, ev.campusId)) return res.status(403).json({ code: 'CHURCH_WIDE_EVENT', message: CHURCH_WIDE_MSG });
req.scopedEvent = ev;
return next();
} catch (e) {
return next(e);
}
};
mw.campusScope = `event:${resolve}:${need}${ownerOk ? ':owner-ok' : ''}`;
return mw;
}
/**
* Like scopeEvent, for records named in the request body: `targets` is [[field, resolver], …]
* (e.g. [['registrationId', 'registration'], ['eventId', 'event']]). Every target that's given
* is checked. With `orphan: 'deny'`, a scoped user is refused when none of them leads to an
* event (a donation not linked to any event is for admins and unscoped supervisors only).
*/
function scopeBody(targets, { need = 'view', orphan = 'pass' } = {}) {
for (const [, resolve] of targets) if (!RESOLVERS[resolve]) throw new Error(`No campus resolver for "${resolve}"`);
const mw = async (req, res, next) => {
try {
const scope = await scopeFor(req);
if (!scope) return next();
let found = false;
for (const [field, resolve] of targets) {
const value = req.body?.[field];
// A list (e.g. ticketIds) checks every id in it.
for (const id of (Array.isArray(value) ? value : [value]).filter(Boolean).slice(0, 500)) {
const ev = await eventOf(resolve, id);
if (!ev) continue;
found = true;
if (!canView(scope, ev.campusId)) return res.status(404).json(NOT_FOUND);
if (need === 'manage' && !canManage(scope, ev.campusId)) return res.status(403).json({ code: 'CHURCH_WIDE_EVENT', message: CHURCH_WIDE_MSG });
}
}
if (!found && orphan === 'deny') {
return res.status(403).json({ code: 'CAMPUS_SCOPED', message: "Only admins and supervisors for every campus can work with payments that aren't for an event." });
}
return next();
} catch (e) {
return next(e);
}
};
mw.campusScope = `body:${targets.map(t => t.join('→')).join(',')}:${need}:${orphan}`;
return mw;
}
/** Marker: the controller filters what it lists with listWhere(). */
const scopeList = (req, res, next) => next();
scopeList.campusScope = 'list';
/** Admins and unscoped supervisors/staff only. */
const unscopedOnly = async (req, res, next) => {
try {
if (await scopeFor(req)) return res.status(403).json({ code: 'CAMPUS_SCOPED', message: 'Only admins and supervisors for every campus can do this.' });
return next();
} catch (e) {
return next(e);
}
};
unscopedOnly.campusScope = 'unscoped-only';
/** Marker for a route that has nothing to do with any event's campus; the reason is required. */
function campusIndependent(reason) {
if (!reason) throw new Error('campusIndependent needs a reason');
const mw = (req, res, next) => next();
mw.campusScope = `independent: ${reason}`;
return mw;
}
/**
* Combine a controller's `where` with a scope `where` without either overwriting the other's
* OR/AND (spreading would): { AND: [where, extra] }, or `where` itself when there's no scope.
*/
function withScope(where, extra) {
if (!extra || !Object.keys(extra).length) return where;
if (!where || !Object.keys(where).length) return extra;
return { AND: [where, extra] };
}
/**
* Broadcasts (email/WhatsApp, send/preview/schedule): a scoped supervisor may only message
* people at their campuses (home campus) or attendees of events they can see, and can't add
* free-typed addresses (`freeformField`: 'emails' or 'phones'). The placeholder event must be
* one they can see.
*/
function scopeBroadcast({ freeformField }) {
const mw = async (req, res, next) => {
try {
const scope = await scopeFor(req);
if (!scope) return next();
const body = req.body || {};
if (String(body[freeformField] || '').trim()) {
return res.status(403).json({ code: 'CAMPUS_SCOPED', message: 'Supervisors for some campuses can only message people from the list, not typed-in addresses.' });
}
if (body.eventId) {
const ev = await eventOf('event', body.eventId);
if (ev && !canView(scope, ev.campusId)) return res.status(404).json(NOT_FOUND);
}
const ids = Array.isArray(body.userIds) ? [...new Set(body.userIds.filter(x => typeof x === 'string' && x))] : [];
if (ids.length) {
const allowed = await prisma.user.count({
where: {
id: { in: ids },
OR: [
{ homeCampusId: { in: scope.campusIds } },
{ registrations: { some: { event: eventWhere(scope) } } },
],
},
});
if (allowed !== ids.length) {
const n = ids.length - allowed;
return res.status(403).json({ code: 'CAMPUS_SCOPED', message: `${n} of the people selected ${n === 1 ? "isn't" : "aren't"} at your campuses or registered for your events.` });
}
}
return next();
} catch (e) {
return next(e);
}
};
mw.campusScope = 'broadcast';
return mw;
}
/**
* Coupons: a scoped supervisor may see and change a coupon only when it's limited to events,
* all at their campuses (a coupon for every event, or a church-wide event, is unscoped-only).
* `campusIds` are the campuses of the coupon's events.
*/
function couponAllowed(scope, campusIds) {
if (!scope) return true;
return campusIds.length > 0 && campusIds.every(id => id != null && scope.campusIds.includes(id));
}
/** The `where` for a list endpoint: relWhere through `path` ('' = the event itself). */
async function listWhere(req, path = '') {
const scope = await scopeFor(req);
return path ? relWhere(scope, path) : eventWhere(scope);
}
module.exports = {
scopeFor, eventWhere, manageWhere, relWhere, canView, canManage, eventOf, RESOLVERS,
scopeEvent, scopeBody, scopeList, unscopedOnly, campusIndependent, scopeBroadcast, couponAllowed, listWhere, withScope, CHURCH_WIDE_MSG,
};