Campus-scoped staff: every staff/supervisor route checked

Events, payments, registrations, scan locks and tickets marked; list
controllers filtered (registrations, payments, tickets, scans, stats,
coupons, scheduled messages, form responses, funnel). Owner-or-staff routes
check staff against the record's event (ownerOk). Scanner names the campus
of another campus's ticket. Route test passes; unit tests for the rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 17:37:42 +02:00
co-authored by Claude Opus 5.5
parent 344f260011
commit 00fd26f3a5
14 changed files with 323 additions and 95 deletions
+26 -4
View File
@@ -9,6 +9,19 @@ const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const sessionsUtil = require('../utils/sessions');
const { campusesEnabled, campusForEvent, campusWhere, resolveCampusFilter, EVENT_CAMPUS_SELECT } = require('../utils/campuses');
const { scopeFor, canView, canManage, eventWhere: scopeEventWhere, withScope } = require('../utils/campusScope');
/** A campus-scoped supervisor may only create or move events to one of their campuses. */
async function assertCampusAllowed(req, campusId) {
const scope = await scopeFor(req);
if (scope && !canManage(scope, campusId)) {
const err = new Error(campusId == null
? 'Choose one of your campuses — events for all campuses are set up by admins and supervisors for every campus.'
: 'You can only put events at your own campuses.');
err.statusCode = 403;
throw err;
}
}
const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' }, include: { _count: { select: { usages: true } } } };
@@ -128,6 +141,8 @@ const createEvent = async (req, res) => {
const campusPick = await campusForEvent(req.body.campusId, { enabled: await campusesEnabled() });
if (campusPick.error) { const err = new Error(campusPick.error); err.statusCode = 400; throw err; }
// Not choosing a campus means all campuses, which a scoped supervisor can't create.
await assertCampusAllowed(req, campusPick.campusId === undefined ? null : campusPick.campusId);
const data = {
id: uuidv4(),
@@ -426,6 +441,7 @@ const getEventsAll = async (req, res) => {
// ?campus=<id, slug or 'all'>: one campus's events, or only church-wide ones.
const enabled = await campusesEnabled();
Object.assign(where, campusWhere(enabled ? await resolveCampusFilter(req.query.campus) : '', { enabled }));
const scoped = withScope(where, scopeEventWhere(await scopeFor(req)));
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
@@ -433,7 +449,7 @@ const getEventsAll = async (req, res) => {
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) }
: undefined;
const events = await prisma.event.findMany({
where,
where: scoped,
include: {
eventOptions: optionInclude ? { include: optionInclude } : true,
form: { include: { fields: true } },
@@ -510,9 +526,12 @@ const getEventById = async (req, res) => {
}
// Public gating: hide inactive or not-yet-live events from everyone except staff/supervisor/admin,
// who need to load inactive events for cashup, editing, etc.
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role));
// who need to load inactive events for cashup, editing, etc. Campus-scoped staff get the
// public view of other campuses' events (and not their notification recipients).
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role)) && canView(await scopeFor(req), event.campusId);
if (!isStaffOrHigher) {
delete event.createdBy;
delete event.notifyRecipients;
if (event.isActive === false) {
res.status(404);
throw new Error('Event not found');
@@ -641,7 +660,7 @@ const getEventIcs = async (req, res) => {
// Same public-visibility gate as getEventById — a hidden/not-yet-live event's
// details shouldn't be enumerable via this route either.
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role));
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role)) && canView(await scopeFor(req), event.campusId);
if (!isStaffOrHigher) {
if (event.isActive === false) {
res.status(404);
@@ -705,6 +724,9 @@ const updateEvent = async (req, res) => {
const campusPick = await campusForEvent(req.body.campusId, { enabled: await campusesEnabled(), currentCampusId: event.campusId });
if (campusPick.error) throw new Error(campusPick.error);
const campusChanged = campusPick.campusId !== undefined && campusPick.campusId !== event.campusId;
if (campusChanged) {
try { await assertCampusAllowed(req, campusPick.campusId); } catch (e) { return res.status(403).json({ message: e.message }); }
}
const data = {
title: title || event.title,
+6 -2
View File
@@ -351,9 +351,13 @@ const getPayments = async (req, res) => {
event: true
};
// Campus-scoped supervisors: payments for events they can see (through the payment's event or
// its registration's), so unlinked donations aren't listed for them.
const scopeW = require('../utils/campusScope').eventWhere(await require('../utils/campusScope').scopeFor(req));
const where = Object.keys(scopeW).length ? { OR: [{ event: scopeW }, { registration: { event: scopeW } }] } : {};
const [payments, total] = await prisma.$transaction([
prisma.payment.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
prisma.payment.count()
prisma.payment.findMany({ where, include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
prisma.payment.count({ where })
]);
res.json({ data: payments, total, page, limit, pages: Math.ceil(total / limit) });
@@ -565,8 +565,10 @@ const getRegistrations = async (req, res) => {
// Pagination is opt-in via ?page/?limit to keep existing callers (which expect a plain
// array of every registration) working unchanged; callers that pass either param get back
// the { data, total, page, limit, pages } shape used by /api/payments and /api/users.
// Campus-scoped staff: registrations for events they can see.
const where = await require('../utils/campusScope').listWhere(req, 'event');
if (typeof req.query.page === 'undefined' && typeof req.query.limit === 'undefined') {
const registrations = await prisma.registration.findMany({ include });
const registrations = await prisma.registration.findMany({ where, include });
return res.json(attachComputedTotalsToList(registrations));
}
@@ -575,8 +577,8 @@ const getRegistrations = async (req, res) => {
const skip = (page - 1) * limit;
const [registrations, total] = await prisma.$transaction([
prisma.registration.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
prisma.registration.count()
prisma.registration.findMany({ where, include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
prisma.registration.count({ where })
]);
res.json({ data: attachComputedTotalsToList(registrations), total, page, limit, pages: Math.ceil(total / limit) });
+12 -5
View File
@@ -50,6 +50,8 @@ const getTickets = async (req, res) => {
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 100));
const skip = (page - 1) * limit;
// Campus-scoped supervisors: tickets for events they can see.
const scopeW = await require('../utils/campusScope').listWhere(req, 'event');
const include = {
registrationOption: {
@@ -66,8 +68,8 @@ const getTickets = async (req, res) => {
};
const [tickets, total] = await prisma.$transaction([
prisma.ticket.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
prisma.ticket.count()
prisma.ticket.findMany({ where: scopeW, include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
prisma.ticket.count({ where: scopeW })
]);
res.json({ data: tickets, total, page, limit, pages: Math.ceil(total / limit) });
@@ -632,9 +634,11 @@ const getRecentScans = async (req, res) => {
try {
const limit = Math.max(1, Math.min(parseInt(req.query.limit) || 10, 100));
const eventId = req.query.eventId || undefined;
// Campus-scoped staff: scans of tickets for events they can see.
const scopeEv = (await require('../utils/campusScope').listWhere(req, 'event')).event;
const scans = await prisma.ticketUsage.findMany({
where: eventId ? { ticket: { eventId } } : undefined,
where: eventId || scopeEv ? { ticket: { ...(eventId ? { eventId } : {}), ...(scopeEv ? { event: scopeEv } : {}) } } : undefined,
orderBy: { scannedAt: 'desc' },
take: limit,
select: {
@@ -666,11 +670,14 @@ const getScanStats = async (req, res) => {
const eventId = req.query.eventId || undefined;
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
// Campus-scoped staff: scans of tickets for events they can see.
const scopeEv = (await require('../utils/campusScope').listWhere(req, 'event')).event;
const ticketWhere = eventId || scopeEv ? { ticket: { ...(eventId ? { eventId } : {}), ...(scopeEv ? { event: scopeEv } : {}) } } : {};
// Total scans today (optionally by event)
const whereBase = {
scannedAt: { gte: startOfDay },
...(eventId ? { ticket: { eventId } } : {})
...ticketWhere
};
const [totalToday, myToday, lastHour] = await Promise.all([
@@ -678,7 +685,7 @@ const getScanStats = async (req, res) => {
prisma.ticketUsage.count({ where: { ...whereBase, scannedById: req.user.id } }),
prisma.ticketUsage.count({
where: {
...(eventId ? { ticket: { eventId } } : {}),
...ticketWhere,
scannedAt: { gte: new Date(Date.now() - 60 * 60 * 1000) }
}
})
+1 -1
View File
@@ -12,7 +12,7 @@ router.get('/events/:eventId', protect, staff, scopeEvent(), listEventConsent);
router.post('/events/:eventId/resend-pending', protect, supervisor, scopeEvent(), resendPending);
router.post('/events/:eventId/rerequest', protect, supervisor, scopeEvent(), rerequestConsent);
router.get('/events/:eventId/medical', protect, supervisor, scopeEvent(), medicalList);
router.post('/requests/:id/resend', protect, resendRequest);
router.post('/requests/:id/resend', protect, scopeEvent({ resolve: 'consentRequest', ownerOk: true }), resendRequest);
// Public consent page
router.get('/:token', consentLimiter, getConsent);
+2 -2
View File
@@ -2,10 +2,10 @@ const express = require('express');
const router = express.Router();
const { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions } = require('../controllers/couponController');
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
const { scopeList } = require('../utils/campusScope');
const { scopeEvent, scopeList } = require('../utils/campusScope');
// Check a code against a basket before registering (guests included)
router.post('/validate', optionalAuth, validateCode);
router.post('/validate', optionalAuth, scopeEvent({ from: req => req.body?.eventId }), validateCode);
router.get('/', protect, supervisor, scopeList, listCoupons);
router.post('/', protect, supervisor, scopeList, createCoupon);
+15 -14
View File
@@ -29,6 +29,7 @@ const {
getEventByAlias
} = require('../controllers/eventController');
const { protect, admin, supervisor, optionalAuth } = require('../middleware/authMiddleware');
const { scopeEvent, scopeList } = require('../utils/campusScope');
// Public routes
router.get('/', getEvents);
@@ -49,40 +50,40 @@ router.get('/:id/ics', optionalAuth, getEventIcs);
router.get('/by-alias/:redirectUrl', getEventByAlias);
// Create/Update/Delete event
router.post('/', protect, supervisor, createEvent);
router.post('/', protect, supervisor, scopeList, createEvent);
router.route('/:id')
.put(protect, supervisor, updateEvent)
.put(protect, supervisor, scopeEvent({ need: 'manage' }), updateEvent)
.delete(protect, admin, deleteEvent);
// Notification recipients: who gets registration/payment/daily-summary emails for this event
router.route('/:id/notify-recipients')
.get(protect, supervisor, getEventNotifyRecipients)
.put(protect, supervisor, updateEventNotifyRecipients);
.get(protect, supervisor, scopeEvent(), getEventNotifyRecipients)
.put(protect, supervisor, scopeEvent({ need: 'manage' }), updateEventNotifyRecipients);
// Attachments routes
router.get('/:id/attachments', listEventAttachments); // public read (used by event detail page)
router.post('/:id/attachments', protect, supervisor, ...uploadEventAttachment);
router.delete('/:eventId/attachments/:attachmentId', protect, supervisor, deleteEventAttachment);
router.post('/:id/attachments', protect, supervisor, scopeEvent({ need: 'manage' }), ...uploadEventAttachment);
router.delete('/:eventId/attachments/:attachmentId', protect, supervisor, scopeEvent({ need: 'manage' }), deleteEventAttachment);
// Bulk email attendees
router.post('/:id/email-attendees', protect, supervisor, emailEventAttendees);
router.post('/:id/email-attendees', protect, supervisor, scopeEvent(), emailEventAttendees);
// Schedule bulk email to attendees
router.post('/:id/email-attendees/schedule', protect, supervisor, scheduleEmailEventAttendees);
router.post('/:id/email-attendees/schedule', protect, supervisor, scopeEvent(), scheduleEmailEventAttendees);
// Bulk WhatsApp message to attendees
router.post('/:id/whatsapp-attendees', protect, supervisor, whatsappEventAttendees);
router.post('/:id/whatsapp-attendees', protect, supervisor, scopeEvent(), whatsappEventAttendees);
// Schedule bulk WhatsApp message to attendees
router.post('/:id/whatsapp-attendees/schedule', protect, supervisor, scheduleWhatsappEventAttendees);
router.post('/:id/whatsapp-attendees/schedule', protect, supervisor, scopeEvent(), scheduleWhatsappEventAttendees);
// Event options routes
router.post('/:id/options', protect, supervisor, createEventOption);
router.post('/:id/options', protect, supervisor, scopeEvent({ need: 'manage' }), createEventOption);
router.route('/options/:id')
.put(protect, supervisor, updateEventOption)
.put(protect, supervisor, scopeEvent({ resolve: 'eventOption', need: 'manage' }), updateEventOption)
.delete(protect, admin, deleteEventOption);
// Option variant routes
router.post('/options/:id/variants', protect, supervisor, createOptionVariant);
router.post('/options/:id/variants', protect, supervisor, scopeEvent({ resolve: 'eventOption', need: 'manage' }), createOptionVariant);
router.route('/variants/:id')
.put(protect, supervisor, updateOptionVariant)
.put(protect, supervisor, scopeEvent({ resolve: 'optionVariant', need: 'manage' }), updateOptionVariant)
.delete(protect, admin, deleteOptionVariant);
module.exports = router;
+19 -13
View File
@@ -17,32 +17,38 @@ const {
} = require('../controllers/paymentController');
const { listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider, listOpenCheckouts, checkCheckoutStatus } = require('../controllers/paymentProvidersController');
const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware');
const { scopeEvent, scopeBody, scopeList, unscopedOnly } = require('../utils/campusScope');
// Campus-scoped staff: payments follow their registration's or event's campus. Door payments for
// church-wide events are allowed; refunds of them aren't (need 'manage'); a payment or donation
// not linked to any event is for admins and unscoped supervisors (orphan 'deny').
const paymentTarget = scopeBody([['registrationId', 'registration'], ['eventId', 'event']], { orphan: 'deny' });
// Protected routes
router.post('/', protect, supervisor, createPayment);
router.post('/', protect, supervisor, paymentTarget, createPayment);
router.get('/providers', listPaymentProviders);
router.get('/pay/:id', getPayPage);
router.post('/pay/:id', choosePayProvider);
router.get('/checkouts/open', protect, supervisor, listOpenCheckouts);
router.post('/checkouts/check', protect, supervisor, checkCheckoutStatus);
router.get('/checkouts/open', protect, supervisor, unscopedOnly, listOpenCheckouts);
router.post('/checkouts/check', protect, supervisor, unscopedOnly, checkCheckoutStatus);
router.get('/providers/settings', protect, admin, getPaymentProviderSettings);
router.put('/providers/settings', protect, admin, updatePaymentProviderSettings);
router.post('/checkout', protect, createCheckout);
router.post('/checkout/send', protect, supervisor, sendPaymentLink);
router.post('/checkout/send', protect, supervisor, scopeBody([['registrationId', 'registration']]), sendPaymentLink);
// Older names, kept for existing clients
router.post('/yoco-checkout', protect, createCheckout);
router.post('/yoco-checkout/send', protect, supervisor, sendPaymentLink);
router.post('/yoco-checkout/send', protect, supervisor, scopeBody([['registrationId', 'registration']]), sendPaymentLink);
router.get('/mypayments', protect, getUserPayments);
router.post('/:id/send-receipt', protect, sendReceipt);
router.get('/:id', protect, getPaymentById);
router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
router.post('/:id/send-receipt', protect, scopeEvent({ resolve: 'payment', ownerOk: true }), sendReceipt);
router.get('/:id', protect, scopeEvent({ resolve: 'payment', ownerOk: true }), getPaymentById);
router.get('/registration/:registrationId', protect, scopeEvent({ resolve: 'registration', from: req => req.params.registrationId, ownerOk: true }), getPaymentsByRegistration);
// Admin/Staff routes
router.get('/', protect, supervisor, getPayments);
router.get('/event/:eventId', protect, staff, getPaymentsByEvent);
router.put('/assign-donation', protect, supervisor, assignDonationToRegistration);
router.post('/unassign-donation', protect, supervisor, unassignDonationFromRegistration);
router.post('/refund', protect, supervisor, createRefund);
router.get('/', protect, supervisor, scopeList, getPayments);
router.get('/event/:eventId', protect, staff, scopeEvent(), getPaymentsByEvent);
router.put('/assign-donation', protect, supervisor, scopeBody([['paymentId', 'payment']], { orphan: 'deny' }), scopeBody([['registrationId', 'registration']]), assignDonationToRegistration);
router.post('/unassign-donation', protect, supervisor, scopeBody([['legId', 'payment']], { orphan: 'deny' }), unassignDonationFromRegistration);
router.post('/refund', protect, supervisor, scopeBody([['paymentId', 'payment'], ['registrationId', 'registration']], { need: 'manage', orphan: 'deny' }), createRefund);
router.get('/admin/stats', protect, admin, getPaymentStats);
module.exports = router;
+20 -16
View File
@@ -17,29 +17,33 @@ const {
updateAttendees, sendAttendeeFormLink,
} = require('../controllers/registrationController');
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
const { scopeEvent, scopeList } = require('../utils/campusScope');
// Campus-scoped staff (utils/campusScope.js). Routes open to the registration's owner as well
// as to staff check staff against the registration's event, and let the owner through (ownerOk).
// Registration - auth optional when event doesn't require it
router.post('/', optionalAuth, createRegistration);
router.get('/myregistrations', protect, getUserRegistrations);
router.put('/:id/options', protect, updateRegistrationOptions);
router.delete('/:id', protect, cancelRegistration);
router.post('/:id/send-invoice', protect, sendInvoice);
router.post('/:id/coupon', protect, applyCoupon);
router.delete('/:id/coupon', protect, staff, removeCoupon);
router.put('/:id/attendees', protect, updateAttendees);
router.post('/:id/attendees/:attendeeId/send-form-link', protect, sendAttendeeFormLink);
router.put('/:id/options', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), updateRegistrationOptions);
router.delete('/:id', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), cancelRegistration);
router.post('/:id/send-invoice', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), sendInvoice);
router.post('/:id/coupon', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), applyCoupon);
router.delete('/:id/coupon', protect, staff, scopeEvent({ resolve: 'registration' }), removeCoupon);
router.put('/:id/attendees', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), updateAttendees);
router.post('/:id/attendees/:attendeeId/send-form-link', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), sendAttendeeFormLink);
// Registration detail + forms — optionalAuth so guests can access with just the registrationId
router.get('/:id', optionalAuth, getRegistrationById);
router.get('/:id/forms/draft', optionalAuth, getFormDraft);
router.put('/:id/forms/draft', optionalAuth, saveFormDraft);
router.post('/:id/forms/responses', optionalAuth, submitFormResponses);
router.put('/:id/forms/responses', protect, supervisor, replaceFormResponses);
router.get('/:id', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), getRegistrationById);
router.get('/:id/forms/draft', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), getFormDraft);
router.put('/:id/forms/draft', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), saveFormDraft);
router.post('/:id/forms/responses', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), submitFormResponses);
router.put('/:id/forms/responses', protect, supervisor, scopeEvent({ resolve: 'registration' }), replaceFormResponses);
// Admin/Staff routes
router.get('/', protect, staff, getRegistrations);
router.put('/:id', protect, staff, updateRegistrationStatus);
router.get('/event/:eventId', protect, staff, getRegistrationsByEvent);
router.post('/manual', protect, supervisor, createManualRegistration);
router.get('/', protect, staff, scopeList, getRegistrations);
router.put('/:id', protect, staff, scopeEvent({ resolve: 'registration' }), updateRegistrationStatus);
router.get('/event/:eventId', protect, staff, scopeEvent(), getRegistrationsByEvent);
router.post('/manual', protect, supervisor, scopeEvent({ from: req => req.body?.eventId }), createManualRegistration);
module.exports = router;
+9 -8
View File
@@ -2,14 +2,15 @@ const express = require('express');
const router = express.Router();
const { listLocks, createLock, getSnapshot, syncLock, releaseLock, forceRelease, listReports, reviewReport } = require('../controllers/scanLockController');
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
const { scopeEvent } = require('../utils/campusScope');
router.get('/', protect, staff, listLocks);
router.post('/', protect, staff, createLock);
router.get('/reports', protect, supervisor, listReports);
router.post('/reports/:id/review', protect, supervisor, reviewReport);
router.get('/:id/snapshot', protect, staff, getSnapshot);
router.post('/:id/sync', protect, staff, syncLock);
router.post('/:id/force-release', protect, supervisor, forceRelease);
router.delete('/:id', protect, staff, releaseLock);
router.get('/', protect, staff, scopeEvent({ from: req => req.query.eventId }), listLocks);
router.post('/', protect, staff, scopeEvent({ from: req => req.body?.eventId }), createLock);
router.get('/reports', protect, supervisor, scopeEvent({ from: req => req.query.eventId }), listReports);
router.post('/reports/:id/review', protect, supervisor, scopeEvent({ resolve: 'syncReport' }), reviewReport);
router.get('/:id/snapshot', protect, staff, scopeEvent({ resolve: 'scanLock' }), getSnapshot);
router.post('/:id/sync', protect, staff, scopeEvent({ resolve: 'scanLock' }), syncLock);
router.post('/:id/force-release', protect, supervisor, scopeEvent({ resolve: 'scanLock' }), forceRelease);
router.delete('/:id', protect, staff, scopeEvent({ resolve: 'scanLock' }), releaseLock);
module.exports = router;
+16 -13
View File
@@ -22,31 +22,34 @@ const {
getTicketTransfers
} = require('../controllers/ticketController');
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
const { scopeEvent, scopeBody, scopeList } = require('../utils/campusScope');
// Campus-scoped staff (utils/campusScope.js). The scanner names the campus of another campus's ticket.
// Protected routes
router.get('/mytickets', protect, getUserTickets);
router.get('/:id', protect, getTicketById);
router.get('/:id', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), getTicketById);
router.post('/email', protect, emailTickets);
router.post('/:id/transfer', protect, transferTicketHandler);
router.delete('/:id/transfer', protect, cancelTransferHandler);
router.post('/:id/transfer', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), transferTicketHandler);
router.delete('/:id/transfer', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), cancelTransferHandler);
// Transfer links (public: the person the ticket is offered to)
router.get('/transfer-offer/:token', getTransferOffer);
router.post('/transfer-offer/:token/accept', acceptTransferOffer);
router.post('/transfer-offer/:token/decline', declineTransferOffer);
router.get('/:id/transfers', protect, getTicketTransfers);
router.get('/:id/transfers', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), getTicketTransfers);
// Staff routes
router.post('/send-to', protect, staff, sendTicketsTo);
router.get('/scan-preview/:qrCode', protect, staff, getScanPreview);
router.get('/qr/:qrCode', protect, staff, getTicketByQrCode);
router.post('/scan/:qrCode', protect, staff, scanTicket);
router.get('/event/:eventId', protect, staff, getTicketsByEvent);
router.get('/scans/recent', protect, staff, getRecentScans);
router.get('/scans/stats', protect, staff, getScanStats);
router.post('/send-to', protect, staff, scopeBody([['registrationId', 'registration'], ['ticketIds', 'ticket']]), sendTicketsTo);
router.get('/scan-preview/:qrCode', protect, staff, scopeEvent({ resolve: 'ticketQr', from: req => req.params.qrCode, nameCampus: true }), getScanPreview);
router.get('/qr/:qrCode', protect, staff, scopeEvent({ resolve: 'ticketQr', from: req => req.params.qrCode, nameCampus: true }), getTicketByQrCode);
router.post('/scan/:qrCode', protect, staff, scopeEvent({ resolve: 'ticketQr', from: req => req.params.qrCode, nameCampus: true }), scanTicket);
router.get('/event/:eventId', protect, staff, scopeEvent(), getTicketsByEvent);
router.get('/scans/recent', protect, staff, scopeList, getRecentScans);
router.get('/scans/stats', protect, staff, scopeList, getScanStats);
// Admin routes
router.post('/generate', protect, supervisor, generateTickets);
router.get('/', protect, supervisor, getTickets);
router.post('/generate', protect, supervisor, scopeBody([['registrationId', 'registration']]), generateTickets);
router.get('/', protect, supervisor, scopeList, getTickets);
router.put('/email-sent', protect, admin, markTicketsAsEmailSent);
module.exports = router;
+65 -10
View File
@@ -10,7 +10,7 @@
* 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 its list with listWhere()
* 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
*/
@@ -69,17 +69,24 @@ function canManage(scope, 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 => (ev ? { id: ev.id, campusId: ev.campusId, campusName: ev.campus?.name || null } : null);
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 => shape((await prisma.registration.findUnique({ where: { id }, select: { event: EV } }))?.event),
ticket: async id => shape((await prisma.ticket.findUnique({ where: { id }, select: { event: EV } }))?.event),
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: { event: EV, registration: { select: { event: EV } } } });
return shape(p?.event || p?.registration?.event);
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),
@@ -91,6 +98,10 @@ const RESOLVERS = {
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]);
},
};
/** The event a record belongs to (see RESOLVERS), or null. */
@@ -111,8 +122,12 @@ const CHURCH_WIDE_MSG = 'This is an event for all campuses — only admins and s
* 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' } = {}) {
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 {
@@ -120,7 +135,11 @@ function scopeEvent({ resolve = 'event', from = req => req.params.eventId || req
if (!scope) return next();
const ev = await eventOf(resolve, from(req));
if (!ev) return next();
if (!canView(scope, ev.campusId)) return res.status(404).json(NOT_FOUND);
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();
@@ -128,7 +147,43 @@ function scopeEvent({ resolve = 'event', from = req => req.params.eventId || req
return next(e);
}
};
mw.campusScope = `event:${resolve}:${need}`;
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;
}
@@ -227,5 +282,5 @@ async function listWhere(req, path = '') {
module.exports = {
scopeFor, eventWhere, manageWhere, relWhere, canView, canManage, eventOf, RESOLVERS,
scopeEvent, scopeList, unscopedOnly, campusIndependent, scopeBroadcast, couponAllowed, listWhere, withScope, CHURCH_WIDE_MSG,
scopeEvent, scopeBody, scopeList, unscopedOnly, campusIndependent, scopeBroadcast, couponAllowed, listWhere, withScope, CHURCH_WIDE_MSG,
};
+124
View File
@@ -0,0 +1,124 @@
const cs = require('../src/utils/campusScope');
const north = { campusIds: ['north'], names: ['North'] };
describe('campus scope rules', () => {
test('unscoped sees and manages everything', () => {
expect(cs.canView(null, 'south')).toBe(true);
expect(cs.canManage(null, null)).toBe(true);
expect(cs.eventWhere(null)).toEqual({});
});
test('scoped: own campus and church-wide events are visible, other campuses are not', () => {
expect(cs.canView(north, 'north')).toBe(true);
expect(cs.canView(north, null)).toBe(true);
expect(cs.canView(north, 'south')).toBe(false);
});
test('scoped: only own campus events can be managed — not church-wide ones', () => {
expect(cs.canManage(north, 'north')).toBe(true);
expect(cs.canManage(north, null)).toBe(false);
expect(cs.canManage(north, 'south')).toBe(false);
});
test('list filters, also through relations', () => {
const ev = { OR: [{ campusId: { in: ['north'] } }, { campusId: null }] };
expect(cs.eventWhere(north)).toEqual(ev);
expect(cs.manageWhere(north)).toEqual({ campusId: { in: ['north'] } });
expect(cs.relWhere(north, 'registration.event')).toEqual({ registration: { event: ev } });
expect(cs.relWhere(null, 'event')).toEqual({});
});
test('withScope never overwrites the other filter', () => {
const own = { OR: [{ a: 1 }, { b: 2 }] };
const scope = cs.eventWhere(north);
expect(cs.withScope(own, scope)).toEqual({ AND: [own, scope] });
expect(cs.withScope(own, {})).toBe(own);
expect(cs.withScope({}, scope)).toBe(scope);
});
test('coupons: only when limited to events, all at own campuses', () => {
expect(cs.couponAllowed(null, [])).toBe(true);
expect(cs.couponAllowed(north, [])).toBe(false); // every event
expect(cs.couponAllowed(north, ['north'])).toBe(true);
expect(cs.couponAllowed(north, ['north', null])).toBe(false); // a church-wide event
expect(cs.couponAllowed(north, ['north', 'south'])).toBe(false);
});
});
describe('campus scope middleware', () => {
// The scope is preset on the request (scopeFor caches it there) and the resolvers are stubbed,
// so these run without a database.
const saved = { ...cs.RESOLVERS };
const events = {
n1: { id: 'n1', campusId: 'north', campusName: 'North', owners: ['u-owner'] },
s1: { id: 's1', campusId: 'south', campusName: 'South', owners: ['u-owner'] },
all: { id: 'all', campusId: null, campusName: null, owners: [] },
};
beforeAll(() => {
for (const k of ['event', 'registration', 'ticketQr', 'payment']) cs.RESOLVERS[k] = async id => events[id] || null;
});
afterAll(() => Object.assign(cs.RESOLVERS, saved));
const run = async (mw, { scope = north, params = {}, body = {}, userId = 'u-staff' } = {}) => {
const req = { _campusScope: scope, params, body, user: { id: userId, role: 'supervisor' } };
const out = { status: 200, body: null, next: false };
const res = { status(c) { out.status = c; return this; }, json(b) { out.body = b; return this; } };
await mw(req, res, () => { out.next = true; });
return out;
};
test('scopeEvent: own campus and church-wide pass, another campus is "not found"', async () => {
const mw = cs.scopeEvent();
expect((await run(mw, { params: { id: 'n1' } })).next).toBe(true);
expect((await run(mw, { params: { id: 'all' } })).next).toBe(true);
const other = await run(mw, { params: { id: 's1' } });
expect(other.next).toBe(false);
expect(other.status).toBe(404);
});
test('scopeEvent: unscoped always passes; unknown records pass to the controller', async () => {
expect((await run(cs.scopeEvent(), { scope: null, params: { id: 's1' } })).next).toBe(true);
expect((await run(cs.scopeEvent(), { params: { id: 'nope' } })).next).toBe(true);
});
test("scopeEvent need 'manage': church-wide events are refused with a reason", async () => {
const mw = cs.scopeEvent({ need: 'manage' });
expect((await run(mw, { params: { id: 'n1' } })).next).toBe(true);
const all = await run(mw, { params: { id: 'all' } });
expect(all.status).toBe(403);
expect(all.body.code).toBe('CHURCH_WIDE_EVENT');
});
test('scopeEvent ownerOk: the owner of another campus\'s record still gets through', async () => {
const mw = cs.scopeEvent({ resolve: 'registration', ownerOk: true });
expect((await run(mw, { params: { id: 's1' }, userId: 'u-owner' })).next).toBe(true);
expect((await run(mw, { params: { id: 's1' } })).status).toBe(404);
});
test('the scanner names the campus of another campus\'s ticket', async () => {
const mw = cs.scopeEvent({ resolve: 'ticketQr', from: r => r.params.qrCode, nameCampus: true });
const out = await run(mw, { params: { qrCode: 's1' } });
expect(out.status).toBe(403);
expect(out.body).toEqual({ code: 'OTHER_CAMPUS', message: 'This ticket is for an event at South.' });
});
test('scopeBody: every target and every id in a list is checked', async () => {
const mw = cs.scopeBody([['registrationId', 'registration'], ['ticketIds', 'ticket']]);
cs.RESOLVERS.ticket = async id => events[id] || null;
expect((await run(mw, { body: { ticketIds: ['n1', 'all'] } })).next).toBe(true);
expect((await run(mw, { body: { ticketIds: ['n1', 's1'] } })).status).toBe(404);
});
test("scopeBody orphan 'deny': a payment not linked to any event is unscoped-only", async () => {
const mw = cs.scopeBody([['registrationId', 'registration'], ['eventId', 'event']], { orphan: 'deny' });
expect((await run(mw, { body: {} })).status).toBe(403);
expect((await run(mw, { body: { eventId: 'n1' } })).next).toBe(true);
expect((await run(mw, { scope: null, body: {} })).next).toBe(true);
});
test('unscopedOnly refuses scoped users', async () => {
expect((await run(cs.unscopedOnly)).status).toBe(403);
expect((await run(cs.unscopedOnly, { scope: null })).next).toBe(true);
});
});
+3 -4
View File
@@ -13,12 +13,11 @@ function staffRoutes() {
const router = require(path.join(ROUTES_DIR, file));
for (const layer of router.stack || []) {
if (!layer.route) continue;
const handles = layer.route.stack.map(s => s.handle);
const role = handles.includes(staff) || handles.includes(supervisor);
if (!role) continue;
for (const method of Object.keys(layer.route.methods)) {
// router.route('/x').get(...).put(...) puts every method's handlers on one route.
// router.route('/x').get(...).put(...) puts every method's handlers on one route, so the
// role is checked per method (admin-only methods are never campus-scoped).
const forMethod = layer.route.stack.filter(s => s.method === method || s.method === undefined).map(s => s.handle);
if (!forMethod.includes(staff) && !forMethod.includes(supervisor)) continue;
const marker = forMethod.find(h => h && h.campusScope);
out.push({ file, method: method.toUpperCase(), path: layer.route.path, marker: marker ? marker.campusScope : null });
}