diff --git a/backend/src/controllers/eventController.js b/backend/src/controllers/eventController.js index 6b3dc1e..b17ad09 100644 --- a/backend/src/controllers/eventController.js +++ b/backend/src/controllers/eventController.js @@ -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=: 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, diff --git a/backend/src/controllers/paymentController.js b/backend/src/controllers/paymentController.js index 8e7a530..218b95b 100644 --- a/backend/src/controllers/paymentController.js +++ b/backend/src/controllers/paymentController.js @@ -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) }); diff --git a/backend/src/controllers/registrationController.js b/backend/src/controllers/registrationController.js index 25e0df3..48cb959 100644 --- a/backend/src/controllers/registrationController.js +++ b/backend/src/controllers/registrationController.js @@ -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) }); diff --git a/backend/src/controllers/ticketController.js b/backend/src/controllers/ticketController.js index 77cb000..f67a8d4 100644 --- a/backend/src/controllers/ticketController.js +++ b/backend/src/controllers/ticketController.js @@ -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) } } }) diff --git a/backend/src/routes/consentRoutes.js b/backend/src/routes/consentRoutes.js index 35ce016..f73d636 100644 --- a/backend/src/routes/consentRoutes.js +++ b/backend/src/routes/consentRoutes.js @@ -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); diff --git a/backend/src/routes/couponRoutes.js b/backend/src/routes/couponRoutes.js index a8cd7bc..f97f2b2 100644 --- a/backend/src/routes/couponRoutes.js +++ b/backend/src/routes/couponRoutes.js @@ -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); diff --git a/backend/src/routes/eventRoutes.js b/backend/src/routes/eventRoutes.js index 00c5364..8e7d037 100644 --- a/backend/src/routes/eventRoutes.js +++ b/backend/src/routes/eventRoutes.js @@ -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; \ No newline at end of file diff --git a/backend/src/routes/paymentRoutes.js b/backend/src/routes/paymentRoutes.js index bc664ab..b7afcf8 100644 --- a/backend/src/routes/paymentRoutes.js +++ b/backend/src/routes/paymentRoutes.js @@ -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; \ No newline at end of file diff --git a/backend/src/routes/registrationRoutes.js b/backend/src/routes/registrationRoutes.js index 6fa0402..89ea7b6 100644 --- a/backend/src/routes/registrationRoutes.js +++ b/backend/src/routes/registrationRoutes.js @@ -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; \ No newline at end of file diff --git a/backend/src/routes/scanLockRoutes.js b/backend/src/routes/scanLockRoutes.js index 40ac95d..ce569a4 100644 --- a/backend/src/routes/scanLockRoutes.js +++ b/backend/src/routes/scanLockRoutes.js @@ -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; diff --git a/backend/src/routes/ticketRoutes.js b/backend/src/routes/ticketRoutes.js index a46b341..92f02a9 100644 --- a/backend/src/routes/ticketRoutes.js +++ b/backend/src/routes/ticketRoutes.js @@ -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; \ No newline at end of file diff --git a/backend/src/utils/campusScope.js b/backend/src/utils/campusScope.js index cea2f2c..8053d40 100644 --- a/backend/src/utils/campusScope.js +++ b/backend/src/utils/campusScope.js @@ -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, }; diff --git a/backend/tests/campusScope.test.js b/backend/tests/campusScope.test.js new file mode 100644 index 0000000..7d11455 --- /dev/null +++ b/backend/tests/campusScope.test.js @@ -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); + }); +}); diff --git a/backend/tests/campusScopeRoutes.test.js b/backend/tests/campusScopeRoutes.test.js index 9847533..3f3d66a 100644 --- a/backend/tests/campusScopeRoutes.test.js +++ b/backend/tests/campusScopeRoutes.test.js @@ -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 }); }