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>
291 lines
14 KiB
JavaScript
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,
|
|
};
|