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:
@@ -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,
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user