From 955f8d9831a963ed41be2bc323bc1ebb535c331f Mon Sep 17 00:00:00 2001 From: Joshua Oosthuizen Date: Tue, 27 Oct 2026 16:23:33 +0200 Subject: [PATCH] Campuses backend: model, admin/public API, events per campus, filters - Campus model, Event.campusId (null = all campuses), User.homeCampusId, per-campus summary recipients; campuses_enabled setting (off by default) - /api/campuses: public list and landing page, admin CRUD/order/archive, "everyone at campus X" audience (home campus + optional 12-month registrants) - Events: campus on create/update (audited when moved), ?campus= on the public list (with church-wide events) and staff list; campus included - Home campus on the profile; cleared when anonymising - Overview stats ?campus= filter; campus summary recipients; campus on ticket PDFs; heartbeat reports the real campus setting - Migration also adds hub_update_requested, missing since 1.42.0 Co-Authored-By: Claude Opus 5.5 --- .../20261027141209_campuses/migration.sql | 76 +++++++ backend/prisma/schema.prisma | 46 +++++ backend/src/controllers/campusController.js | 187 ++++++++++++++++++ backend/src/controllers/eventController.js | 31 ++- backend/src/controllers/settingsController.js | 4 +- backend/src/controllers/statsController.js | 42 ++-- backend/src/controllers/ticketController.js | 11 +- backend/src/controllers/userController.js | 16 +- backend/src/hub/report.js | 11 +- backend/src/index.js | 1 + backend/src/routes/campusRoutes.js | 20 ++ backend/src/utils/campuses.js | 138 +++++++++++++ backend/src/utils/notifications.js | 9 +- backend/src/utils/privacy.js | 1 + backend/tests/campuses.test.js | 80 ++++++++ 15 files changed, 650 insertions(+), 23 deletions(-) create mode 100644 backend/prisma/migrations/20261027141209_campuses/migration.sql create mode 100644 backend/src/controllers/campusController.js create mode 100644 backend/src/routes/campusRoutes.js create mode 100644 backend/src/utils/campuses.js create mode 100644 backend/tests/campuses.test.js diff --git a/backend/prisma/migrations/20261027141209_campuses/migration.sql b/backend/prisma/migrations/20261027141209_campuses/migration.sql new file mode 100644 index 0000000..e05475d --- /dev/null +++ b/backend/prisma/migrations/20261027141209_campuses/migration.sql @@ -0,0 +1,76 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "AdminAuditAction" ADD VALUE 'hub_update_requested'; +ALTER TYPE "AdminAuditAction" ADD VALUE 'campuses_toggled'; +ALTER TYPE "AdminAuditAction" ADD VALUE 'campus_created'; +ALTER TYPE "AdminAuditAction" ADD VALUE 'campus_updated'; +ALTER TYPE "AdminAuditAction" ADD VALUE 'campus_archived'; +ALTER TYPE "AdminAuditAction" ADD VALUE 'campus_deleted'; +ALTER TYPE "AdminAuditAction" ADD VALUE 'event_campus_changed'; + +-- AlterTable +ALTER TABLE "Event" ADD COLUMN "campusId" TEXT; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "homeCampusId" TEXT; + +-- CreateTable +CREATE TABLE "Campus" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "address" TEXT, + "contactName" TEXT, + "contactEmail" TEXT, + "contactPhone" TEXT, + "colour" TEXT, + "logoUrl" TEXT, + "defaultLocation" TEXT, + "order" INTEGER NOT NULL DEFAULT 0, + "archivedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Campus_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_CampusNotifyRecipients" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_CampusNotifyRecipients_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Campus_slug_key" ON "Campus"("slug"); + +-- CreateIndex +CREATE INDEX "Campus_order_idx" ON "Campus"("order"); + +-- CreateIndex +CREATE INDEX "_CampusNotifyRecipients_B_index" ON "_CampusNotifyRecipients"("B"); + +-- CreateIndex +CREATE INDEX "Event_campusId_idx" ON "Event"("campusId"); + +-- CreateIndex +CREATE INDEX "User_homeCampusId_idx" ON "User"("homeCampusId"); + +-- AddForeignKey +ALTER TABLE "User" ADD CONSTRAINT "User_homeCampusId_fkey" FOREIGN KEY ("homeCampusId") REFERENCES "Campus"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Event" ADD CONSTRAINT "Event_campusId_fkey" FOREIGN KEY ("campusId") REFERENCES "Campus"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_CampusNotifyRecipients" ADD CONSTRAINT "_CampusNotifyRecipients_A_fkey" FOREIGN KEY ("A") REFERENCES "Campus"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_CampusNotifyRecipients" ADD CONSTRAINT "_CampusNotifyRecipients_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 4fde725..60d724e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -141,6 +141,41 @@ model User { noticeAcceptances NoticeAcceptance[] dataAccessLogs DataAccessLog[] breachesRecorded SecurityBreach[] + + // Multi-campus (only used while Site settings → Campuses is on). + homeCampusId String? + homeCampus Campus? @relation("UserHomeCampus", fields: [homeCampusId], references: [id], onDelete: SetNull) + campusSummaries Campus[] @relation("CampusNotifyRecipients") + + @@index([homeCampusId]) +} + +// A campus of the church (Site settings → Campuses, off by default). Events belong to one +// campus, or to all campuses when Event.campusId is null. Archived instead of deleted once +// it has events. +model Campus { + id String @id @default(uuid()) + name String + slug String @unique + address String? + contactName String? + contactEmail String? + contactPhone String? + // Accent colour (#rrggbb) and logo shown with the campus on the public site. + colour String? + logoUrl String? + // Pre-fills Event.location when an event is created for this campus. + defaultLocation String? + order Int @default(0) + archivedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + events Event[] + homeOf User[] @relation("UserHomeCampus") + // Get the daily summary for this campus's events (on top of each event's own recipients). + notifyRecipients User[] @relation("CampusNotifyRecipients") + + @@index([order]) } model Event { @@ -187,6 +222,9 @@ model Event { contactPhone String? contactEmail String? location String? + // Multi-campus: null = all campuses (also every event while campuses are off). + campusId String? + campus Campus? @relation(fields: [campusId], references: [id], onDelete: Restrict) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt createdById String? @@ -246,6 +284,7 @@ model Event { @@index([createdById]) @@index([closedById]) @@index([reopenedById]) + @@index([campusId]) } model EventOption { @@ -603,6 +642,13 @@ enum AdminAuditAction { breach_recorded breach_updated hub_connected + hub_update_requested + campuses_toggled + campus_created + campus_updated + campus_archived + campus_deleted + event_campus_changed } // Append-only audit trail for admin/supervisor-initiated actions with money or diff --git a/backend/src/controllers/campusController.js b/backend/src/controllers/campusController.js new file mode 100644 index 0000000..ab2b8ae --- /dev/null +++ b/backend/src/controllers/campusController.js @@ -0,0 +1,187 @@ +const prisma = require('../config/db'); +const { safeErrorMessage } = require('../utils/errorUtils'); +const { getClientIp } = require('../utils/requestUtils'); +const { logAdminAction } = require('../utils/adminAudit'); +const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache'); +const { SETTING_KEY, CAMPUS_PUBLIC_SELECT, campusesEnabled, cleanCampusInput, campusAudienceWhere } = require('../utils/campuses'); + +const audit = (req, action, campus, metadata) => logAdminAction({ + actorId: req.user.id, actorRole: req.user.role, action, targetType: 'Campus', targetId: campus?.id, metadata: { name: campus?.name, ...metadata }, ip: getClientIp(req), +}); + +// A slug that's already taken → a message instead of a Prisma error. +function friendlyError(e) { + if (e?.code === 'P2002') return 'Another campus already uses that web address — choose a different one.'; + return e.message || safeErrorMessage(e); +} + +const ADMIN_INCLUDE = { + notifyRecipients: { select: { id: true, name: true, email: true } }, + _count: { select: { events: true, homeOf: true } }, +}; + +// @desc Whether campuses are on, and the active campuses (public site filters, event cards) +// @route GET /api/campuses +// @access Public +const getCampuses = async (req, res) => { + try { + const enabled = await campusesEnabled(); + const campuses = enabled + ? await prisma.campus.findMany({ where: { archivedAt: null }, select: CAMPUS_PUBLIC_SELECT, orderBy: [{ order: 'asc' }, { name: 'asc' }] }) + : []; + res.json({ enabled, campuses }); + } catch (e) { + res.status(500).json({ message: safeErrorMessage(e) }); + } +}; + +// @desc A campus landing page: the campus (active only) +// @route GET /api/campuses/by-slug/:slug +// @access Public +const getCampusBySlug = async (req, res) => { + try { + const campus = (await campusesEnabled()) + ? await prisma.campus.findFirst({ where: { slug: String(req.params.slug), archivedAt: null }, select: CAMPUS_PUBLIC_SELECT }) + : null; + if (!campus) return res.status(404).json({ message: 'Campus not found' }); + res.json(campus); + } catch (e) { + res.status(500).json({ message: safeErrorMessage(e) }); + } +}; + +// @desc All campuses with archived ones, event counts and summary recipients (Site settings → Campuses) +// @route GET /api/campuses/admin +// @access Private/Admin +const getCampusesAdmin = async (req, res) => { + try { + const [enabled, campuses] = await Promise.all([ + campusesEnabled(), + prisma.campus.findMany({ include: ADMIN_INCLUDE, orderBy: [{ archivedAt: { sort: 'asc', nulls: 'first' } }, { order: 'asc' }, { name: 'asc' }] }), + ]); + res.json({ enabled, campuses }); + } catch (e) { + res.status(500).json({ message: safeErrorMessage(e) }); + } +}; + +// @desc Turn campuses on or off (off hides campus fields and filters; nothing is deleted) +// @route PUT /api/campuses/settings body: { enabled } +// @access Private/Admin +const updateCampusSettings = async (req, res) => { + try { + const enabled = req.body?.enabled === true; + await prisma.appSetting.upsert({ where: { key: SETTING_KEY }, update: { value: String(enabled) }, create: { key: SETTING_KEY, value: String(enabled) } }); + invalidateSettingsCache(); + await warmCache(); + logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'campuses_toggled', targetType: 'AppSetting', metadata: { enabled }, ip: getClientIp(req) }); + res.json({ enabled }); + } catch (e) { + res.status(500).json({ message: safeErrorMessage(e) }); + } +}; + +// @desc Add a campus (added at the end of the list) +// @route POST /api/campuses +// @access Private/Admin +const createCampus = async (req, res) => { + try { + const { data, error } = cleanCampusInput(req.body); + if (error) return res.status(400).json({ message: error }); + const last = await prisma.campus.aggregate({ _max: { order: true } }); + const campus = await prisma.campus.create({ data: { ...data, order: (last._max.order ?? -1) + 1 }, include: ADMIN_INCLUDE }); + audit(req, 'campus_created', campus, { slug: campus.slug }); + res.status(201).json(campus); + } catch (e) { + res.status(e?.code === 'P2002' ? 409 : 400).json({ message: friendlyError(e) }); + } +}; + +// @desc Change a campus: details, summary recipients, archived or not +// @route PUT /api/campuses/:id body: campus fields, notifyRecipientIds?, archived? +// @access Private/Admin +const updateCampus = async (req, res) => { + try { + const existing = await prisma.campus.findUnique({ where: { id: req.params.id } }); + if (!existing) return res.status(404).json({ message: 'Campus not found' }); + const { data, error } = cleanCampusInput(req.body, { partial: true }); + if (error) return res.status(400).json({ message: error }); + + if (Array.isArray(req.body?.notifyRecipientIds)) { + const ids = req.body.notifyRecipientIds.filter(x => typeof x === 'string' && x); + // Summaries go to people who run events — never to ordinary users. + const staff = await prisma.user.findMany({ where: { id: { in: ids }, role: { in: ['admin', 'supervisor', 'staff'] } }, select: { id: true } }); + data.notifyRecipients = { set: staff.map(u => ({ id: u.id })) }; + } + let archiveChange = null; + if (req.body?.archived !== undefined) { + const archived = req.body.archived === true; + if (archived !== !!existing.archivedAt) { + data.archivedAt = archived ? new Date() : null; + archiveChange = archived; + } + } + + const campus = await prisma.campus.update({ where: { id: existing.id }, data, include: ADMIN_INCLUDE }); + if (archiveChange !== null) audit(req, 'campus_archived', campus, { archived: archiveChange }); + const changed = Object.keys(data).filter(k => k !== 'archivedAt'); + if (changed.length) audit(req, 'campus_updated', campus, { changed }); + res.json(campus); + } catch (e) { + res.status(e?.code === 'P2002' ? 409 : 400).json({ message: friendlyError(e) }); + } +}; + +// @desc Put the campuses in this order (filters and lists follow it) +// @route PUT /api/campuses/order body: { ids: [...] } +// @access Private/Admin +const reorderCampuses = async (req, res) => { + try { + const ids = Array.isArray(req.body?.ids) ? req.body.ids.filter(x => typeof x === 'string') : []; + if (!ids.length) return res.status(400).json({ message: 'Send the campus ids in their new order.' }); + await prisma.$transaction(ids.map((id, order) => prisma.campus.updateMany({ where: { id }, data: { order } }))); + res.json({ ok: true }); + } catch (e) { + res.status(400).json({ message: safeErrorMessage(e) }); + } +}; + +// @desc Delete a campus that has no events (otherwise archive it). Home campus is cleared. +// @route DELETE /api/campuses/:id +// @access Private/Admin +const deleteCampus = async (req, res) => { + try { + const campus = await prisma.campus.findUnique({ where: { id: req.params.id }, include: { _count: { select: { events: true } } } }); + if (!campus) return res.status(404).json({ message: 'Campus not found' }); + if (campus._count.events > 0) { + return res.status(409).json({ code: 'CAMPUS_HAS_EVENTS', message: `${campus.name} has ${campus._count.events} event${campus._count.events === 1 ? '' : 's'}, so it can't be deleted. Archive it instead.` }); + } + await prisma.campus.delete({ where: { id: campus.id } }); + audit(req, 'campus_deleted', campus, { slug: campus.slug }); + res.json({ message: 'Campus deleted' }); + } catch (e) { + res.status(400).json({ message: safeErrorMessage(e) }); + } +}; + +// @desc "Everyone at campus X" for the email/WhatsApp composers: home campus X, plus (with +// ?registrants=true) people registered for X's events in the last 12 months. The +// composer sends to these ids as usual, so news consent is still checked on sending. +// @route GET /api/campuses/:id/audience?registrants=true +// @access Private/Supervisor +const getCampusAudience = async (req, res) => { + try { + const campus = await prisma.campus.findUnique({ where: { id: req.params.id }, select: { id: true, name: true } }); + if (!campus) return res.status(404).json({ message: 'Campus not found' }); + const users = await prisma.user.findMany({ + where: campusAudienceWhere(campus.id, { includeRegistrants: req.query.registrants === 'true' }), + select: { id: true, name: true, email: true, phoneNumber: true }, + orderBy: { name: 'asc' }, + }); + res.json({ campus, users }); + } catch (e) { + res.status(500).json({ message: safeErrorMessage(e) }); + } +}; + +module.exports = { getCampusAudience, getCampuses, getCampusBySlug, getCampusesAdmin, updateCampusSettings, createCampus, updateCampus, reorderCampuses, deleteCampus }; diff --git a/backend/src/controllers/eventController.js b/backend/src/controllers/eventController.js index f476085..6b3dc1e 100644 --- a/backend/src/controllers/eventController.js +++ b/backend/src/controllers/eventController.js @@ -8,6 +8,7 @@ const { assertEventOpen } = require('../utils/cashupUtils'); 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 SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' }, include: { _count: { select: { usages: true } } } }; @@ -125,6 +126,9 @@ const createEvent = async (req, res) => { try { const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body; + const campusPick = await campusForEvent(req.body.campusId, { enabled: await campusesEnabled() }); + if (campusPick.error) { const err = new Error(campusPick.error); err.statusCode = 400; throw err; } + const data = { id: uuidv4(), title, @@ -160,6 +164,7 @@ const createEvent = async (req, res) => { contactPhone: contactPhone || null, contactEmail: contactEmail || null, location: location || null, + ...(campusPick.campusId !== undefined ? { campusId: campusPick.campusId } : {}), ...scheduleData(req.body), }; if (data.scheduleType === 'sessions') { @@ -325,15 +330,19 @@ const getEvents = async (req, res) => { // Determine if Prisma Client supports EarlyBirdTier relation (i.e., migration+generate applied) const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function'); const eventOptionsInclude = canIncludeTiers ? { include: { earlyBirdTiers: true } } : true; + // ?campus=: that campus's events plus church-wide ones. + const enabled = await campusesEnabled(); + const campusFilter = campusWhere(enabled ? await resolveCampusFilter(req.query.campus) : '', { enabled, withAllCampuses: true }); try { events = await prisma.event.findMany({ where: { isActive: true, isHidden: false, goLiveAt: { lte: new Date() }, - endDate: { gte: new Date() } + endDate: { gte: new Date() }, + ...campusFilter, }, - include: { eventOptions: eventOptionsInclude, sessions: SESSIONS_INCLUDE } + include: { eventOptions: eventOptionsInclude, sessions: SESSIONS_INCLUDE, campus: { select: EVENT_CAMPUS_SELECT } } }); } catch (e) { // Fallback if goLiveAt/isHidden not available yet (pre-migration) @@ -383,6 +392,7 @@ const getAllEvents = async (req, res) => { include: { eventOptions: canIncludeTiers ? { include: { earlyBirdTiers: true } } : true, sessions: SESSIONS_INCLUDE, + campus: { select: EVENT_CAMPUS_SELECT }, } }); @@ -413,6 +423,9 @@ const getEventsAll = async (req, res) => { if (!includeInactive) where.isActive = true; if (!includePast) where.endDate = { gte: new Date() }; if (excludeClosed) where.cashupStatus = { not: 'closed' }; + // ?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 canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function'); const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function'); @@ -425,6 +438,7 @@ const getEventsAll = async (req, res) => { eventOptions: optionInclude ? { include: optionInclude } : true, form: { include: { fields: true } }, sessions: SESSIONS_INCLUDE, + campus: { select: EVENT_CAMPUS_SELECT }, }, orderBy: { startDate: 'asc' } }); @@ -452,7 +466,7 @@ const getEventById = async (req, res) => { const optionInclude = canIncludeTiers ? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) } : undefined; - const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: SESSIONS_INCLUDE }; + const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: SESSIONS_INCLUDE, campus: { select: EVENT_CAMPUS_SELECT } }; // Staff/supervisor/admin need the creator + notify-recipient list to populate the // event edit screen's Notifications step; public callers don't need this. @@ -688,6 +702,10 @@ const updateEvent = async (req, res) => { const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body; + 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; + const data = { title: title || event.title, description: description !== undefined ? description : event.description, @@ -725,6 +743,7 @@ const updateEvent = async (req, res) => { contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone, contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail, location: location !== undefined ? (location || null) : event.location, + ...(campusChanged ? { campusId: campusPick.campusId } : {}), updatedAt: new Date(), redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl, ...scheduleData(req.body, event), @@ -749,6 +768,12 @@ const updateEvent = async (req, res) => { : null; }); sessionsUtil.forgetSchedule(updatedEvent.id); + if (campusChanged) { + logAdminAction({ + actorId: req.user.id, actorRole: req.user.role, action: 'event_campus_changed', + targetType: 'Event', targetId: updatedEvent.id, metadata: { from: event.campusId, to: campusPick.campusId }, ip: getClientIp(req), + }); + } if (sessionChanges && Object.values(sessionChanges).some(n => n > 0)) { logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'event_sessions_changed', diff --git a/backend/src/controllers/settingsController.js b/backend/src/controllers/settingsController.js index 6fbed76..8ce140c 100644 --- a/backend/src/controllers/settingsController.js +++ b/backend/src/controllers/settingsController.js @@ -18,7 +18,9 @@ const PAYMENT_PRIVATE_KEYS = new Set([ // The Hub connection (install id and secret, licence token) is only written by src/hub — // an admin editing it here could borrow another install's licence. const HUB_PRIVATE_KEYS = new Set(require('../hub/client').HUB_KEYS); -const PRIVATE_KEYS = new Set([...PAYMENT_PRIVATE_KEYS, ...HUB_PRIVATE_KEYS]); +// Changed through /api/campuses/settings (audited as campuses_toggled), read through GET /api/campuses. +const CAMPUS_KEYS = new Set([require('../utils/campuses').SETTING_KEY]); +const PRIVATE_KEYS = new Set([...PAYMENT_PRIVATE_KEYS, ...HUB_PRIVATE_KEYS, ...CAMPUS_KEYS]); const PUBLIC_KEYS = [ 'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address', diff --git a/backend/src/controllers/statsController.js b/backend/src/controllers/statsController.js index e4af3d3..7f2e2c9 100644 --- a/backend/src/controllers/statsController.js +++ b/backend/src/controllers/statsController.js @@ -1,6 +1,7 @@ const prisma = require('../config/db'); const { safeErrorMessage } = require('../utils/errorUtils'); const { isDonationLeg } = require('../utils/cashupUtils'); +const { campusesEnabled, campusWhere, resolveCampusFilter } = require('../utils/campuses'); // Shared building blocks for the per-dashboard stats endpoints below. Each dashboard // (staff/supervisor/admin) gets exactly one endpoint that returns only what it renders, @@ -53,8 +54,23 @@ function getRecentScans(limit = 10) { }); } -function getActiveEventsCount() { - return prisma.event.count({ where: { isActive: true, endDate: { gte: new Date() } } }); +function getActiveEventsCount(eventWhere = {}) { + return prisma.event.count({ where: { isActive: true, endDate: { gte: new Date() }, ...eventWhere } }); +} + +/** + * `where` pieces for a ?campus= filter (id, slug or 'all'), or nulls for church-wide. A payment + * belongs to a campus through its event, or its registration's event. + */ +async function campusFilters(value) { + const enabled = await campusesEnabled(); + const eventWhere = campusWhere(enabled ? await resolveCampusFilter(value) : '', { enabled }); + if (!Object.keys(eventWhere).length) return { eventWhere: {}, byEvent: {}, payment: {} }; + return { + eventWhere, + byEvent: { event: eventWhere }, + payment: { OR: [{ event: eventWhere }, { registration: { event: eventWhere } }] }, + }; } // A donation-application leg (isDonation:false, originalPaymentId set, amount>0) isn't new @@ -207,21 +223,22 @@ function classifyAndSum(payments) { // @desc Registrations/tickets/revenue/donations for the trailing month (today back one // month) vs the month before that, a daily revenue trend for the trailing month, // and the top performing events overall. -// @route GET /api/stats/overview +// @route GET /api/stats/overview?campus= (no campus = church-wide) // @access Private/Supervisor+ const getOverviewStats = async (req, res) => { try { const now = new Date(); const { thisStart, thisEnd, lastStart, lastEnd } = trailingMonthRanges(now); + const cf = await campusFilters(req.query.campus); const [thisPayments, lastPayments, thisRegistrations, lastRegistrations, thisTickets, lastTickets, activeEvents] = await Promise.all([ - fetchPaymentsWithOriginal({ createdAt: { gte: thisStart, lt: thisEnd } }), - fetchPaymentsWithOriginal({ createdAt: { gte: lastStart, lt: lastEnd } }), - prisma.registration.count({ where: { createdAt: { gte: thisStart, lt: thisEnd }, status: { not: 'cancelled' } } }), - prisma.registration.count({ where: { createdAt: { gte: lastStart, lt: lastEnd }, status: { not: 'cancelled' } } }), - prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: thisStart, lt: thisEnd } } }), - prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: lastStart, lt: lastEnd } } }), - getActiveEventsCount(), + fetchPaymentsWithOriginal({ createdAt: { gte: thisStart, lt: thisEnd }, ...cf.payment }), + fetchPaymentsWithOriginal({ createdAt: { gte: lastStart, lt: lastEnd }, ...cf.payment }), + prisma.registration.count({ where: { createdAt: { gte: thisStart, lt: thisEnd }, status: { not: 'cancelled' }, ...cf.byEvent } }), + prisma.registration.count({ where: { createdAt: { gte: lastStart, lt: lastEnd }, status: { not: 'cancelled' }, ...cf.byEvent } }), + prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: thisStart, lt: thisEnd }, ...cf.byEvent } }), + prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: lastStart, lt: lastEnd }, ...cf.byEvent } }), + getActiveEventsCount(cf.eventWhere), ]); const thisTotals = classifyAndSum(thisPayments); @@ -245,7 +262,7 @@ const getOverviewStats = async (req, res) => { // "real revenue" definition as the KPI above: everything except application legs. const revenueByEvent = await prisma.payment.groupBy({ by: ['eventId'], - where: { ...EXCLUDE_DONATION_LEGS, eventId: { not: null } }, + where: { ...EXCLUDE_DONATION_LEGS, eventId: { not: null }, ...cf.byEvent }, _sum: { amount: true }, orderBy: { _sum: { amount: 'desc' } }, take: 5, @@ -270,7 +287,7 @@ const getOverviewStats = async (req, res) => { // Funnel conversion (event page views → paid) across live events, last 30 days. let funnel = null; try { - const live = await prisma.event.findMany({ where: { isActive: true, endDate: { gte: now } }, select: { id: true } }); + const live = await prisma.event.findMany({ where: { isActive: true, endDate: { gte: now }, ...cf.eventWhere }, select: { id: true } }); if (live.length) { const { funnelFor } = require('../utils/analytics'); const f = await funnelFor(live.map(e => e.id), { from: new Date(now.getTime() - 30 * 864e5).toISOString() }); @@ -296,6 +313,7 @@ const getOverviewStats = async (req, res) => { }; module.exports = { + campusFilters, getStaffDashboardStats, getSupervisorDashboardStats, getAdminDashboardStats, diff --git a/backend/src/controllers/ticketController.js b/backend/src/controllers/ticketController.js index 1132d13..77cb000 100644 --- a/backend/src/controllers/ticketController.js +++ b/backend/src/controllers/ticketController.js @@ -103,7 +103,7 @@ const getUserTickets = async (req, res) => { }, }, user: { select: { id: true, name: true } }, - event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } }, + event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } }, campus: { select: { name: true, slug: true } } } }, transferRequests: { where: { status: 'pending' }, select: { id: true, toName: true, expiresAt: true } }, usages: { include: { @@ -929,6 +929,12 @@ function drawTicketCard(doc, { x, y, w, h }, ticket, qrDataUrl, buyerName) { doc.text(ticket.event.title, x + pad, cy, { width: titleW }); cy += doc.heightOfString(ticket.event.title, { width: titleW }) + 4; + // Campus (only included while campuses are on) + if (ticket.event.campus?.name) { + doc.font('Helvetica').fontSize(9).fillColor(gray500).text(ticket.event.campus.name, x + pad, cy, { width: innerW }); + cy += doc.heightOfString(ticket.event.campus.name, { width: innerW }) + 2; + } + // Ticket type const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : ''; const type = `${ticket.registrationOption.eventOption.name}${variantSuffix}`; @@ -983,8 +989,9 @@ async function emailTicketsInternal(req, res, userOverride) { return res.json({ message: 'No valid contact details on file — ticket delivery skipped.' }); } + const showCampus = await require('../utils/campuses').campusesEnabled(); const ticketInclude = { - event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } }, + event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } }, ...(showCampus ? { campus: { select: { name: true } } } : {}) } }, registrationOption: { include: { eventOption: true, variant: true, session: true, registration: { select: { ticketDelivery: true } } } }, attendee: { include: { allocations: { where: { set: { showOnTicket: true } }, include: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } } } }, user: { select: { name: true } }, diff --git a/backend/src/controllers/userController.js b/backend/src/controllers/userController.js index cb96081..570e474 100644 --- a/backend/src/controllers/userController.js +++ b/backend/src/controllers/userController.js @@ -537,6 +537,7 @@ const getUserProfile = async (req, res) => { createdAt: true, updatedAt: true, isActive: true, + homeCampusId: true, newsAskedAt: true, noticeVersionAccepted: true } @@ -571,7 +572,18 @@ const updateUserProfile = async (req, res) => { throw new Error('User not found'); } - const { name, email, password, phoneNumber, currentPassword, notificationPreference } = req.body || {}; + const { name, email, password, phoneNumber, currentPassword, notificationPreference, homeCampusId } = req.body || {}; + + // Home campus (only while campuses are on): an active campus, or ''/null for none. + let newHomeCampusId = user.homeCampusId; + if (homeCampusId !== undefined && await require('../utils/campuses').campusesEnabled()) { + if (!homeCampusId) newHomeCampusId = null; + else { + const campus = await prisma.campus.findFirst({ where: { id: String(homeCampusId), archivedAt: null }, select: { id: true } }); + if (!campus) { res.status(400); throw new Error('Choose one of the campuses in the list.'); } + newHomeCampusId = campus.id; + } + } // If a password change is requested, verify current password for safety let newHashedPassword = undefined; @@ -619,6 +631,7 @@ const updateUserProfile = async (req, res) => { phoneNumber: newPhone, notificationPreference: newPref, twoFactorMethod: newTwoFactorMethod, + homeCampusId: newHomeCampusId, updatedAt: new Date() }, select: { @@ -633,6 +646,7 @@ const updateUserProfile = async (req, res) => { createdAt: true, updatedAt: true, isActive: true, + homeCampusId: true, tokenVersion: true } }); diff --git a/backend/src/hub/report.js b/backend/src/hub/report.js index 800e4a3..da53be3 100644 --- a/backend/src/hub/report.js +++ b/backend/src/hub/report.js @@ -96,6 +96,7 @@ async function health() { } async function stats(now = new Date()) { + const { campusesEnabled } = require('../utils/campuses'); const since = new Date(now.getTime() - 30 * DAY); const realUser = { isActive: true, NOT: [{ email: { endsWith: '@deleted.invalid' } }, { email: { endsWith: '@guest.local' } }] }; const [users, activeEvents, upcomingEvents, totalEvents, regs30, regsTotal, tickets30, ticketsTotal, checkIns30, income30, incomeAll, providers] = await Promise.all([ @@ -112,6 +113,8 @@ async function stats(now = new Date()) { prisma.payment.aggregate({ _sum: { amount: true } }), require('../payments').availableProviders().then(list => list.map(p => p.id)).catch(() => []), ]); + const campusesOn = await campusesEnabled().catch(() => false); + const campusCount = campusesOn ? await prisma.campus.count({ where: { archivedAt: null } }) : 0; return { users, events: { active: activeEvents, upcoming: upcomingEvents, total: totalEvents }, @@ -121,7 +124,8 @@ async function stats(now = new Date()) { // Messages aren't logged one by one, so there's nothing to count yet. messages: null, paymentProviders: providers, - campuses: false, + campuses: campusesOn, + campusCount, income: { last30d: incomeBand(income30._sum.amount), allTime: incomeBand(incomeAll._sum.amount) }, }; } @@ -129,11 +133,12 @@ async function stats(now = new Date()) { /** What the install says about itself when registering. */ async function installInfo() { const { getSetting } = require('../utils/settingsCache'); - const [name, url, waConfigured, providers] = await Promise.all([ + const [name, url, waConfigured, providers, campusesOn] = await Promise.all([ getSetting('org_name', ''), getSetting('app_base_url', process.env.FRONTEND_URL || ''), require('../utils/whatsapp').isConfigured().catch(() => false), require('../payments').availableProviders().then(list => list.map(p => p.id)).catch(() => []), + require('../utils/campuses').campusesEnabled().catch(() => false), ]); return { name: name || 'Cross Events', @@ -141,7 +146,7 @@ async function installInfo() { version: APP_VERSION, timezone: process.env.APP_TIMEZONE || 'Africa/Johannesburg', server: { node: process.versions.node, postgres: await postgresVersion(), os: `${os.platform()} ${os.release()}` }, - features: { whatsapp: !!waConfigured, paymentProviders: providers, campuses: false }, + features: { whatsapp: !!waConfigured, paymentProviders: providers, campuses: campusesOn }, }; } diff --git a/backend/src/index.js b/backend/src/index.js index 70915fb..2c22847 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -182,6 +182,7 @@ app.get('/health', async (req, res) => { // Use routes app.use('/api/hub', require('./routes/hubRoutes')); +app.use('/api/campuses', require('./routes/campusRoutes')); app.use('/api/users', userRoutes); app.use('/api/events', eventRoutes); app.use('/api/registrations', registrationRoutes); diff --git a/backend/src/routes/campusRoutes.js b/backend/src/routes/campusRoutes.js new file mode 100644 index 0000000..f404129 --- /dev/null +++ b/backend/src/routes/campusRoutes.js @@ -0,0 +1,20 @@ +const express = require('express'); +const router = express.Router(); +const { + getCampuses, getCampusBySlug, getCampusesAdmin, updateCampusSettings, createCampus, updateCampus, reorderCampuses, deleteCampus, + getCampusAudience, +} = require('../controllers/campusController'); +const { protect, admin, supervisor } = require('../middleware/authMiddleware'); + +// Multi-campus (utils/campuses.js). The public list is empty while campuses are off. +router.get('/', getCampuses); +router.get('/by-slug/:slug', getCampusBySlug); +router.get('/admin', protect, admin, getCampusesAdmin); +router.put('/settings', protect, admin, updateCampusSettings); +router.put('/order', protect, admin, reorderCampuses); +router.post('/', protect, admin, createCampus); +router.put('/:id', protect, admin, updateCampus); +router.delete('/:id', protect, admin, deleteCampus); +router.get('/:id/audience', protect, supervisor, getCampusAudience); + +module.exports = router; diff --git a/backend/src/utils/campuses.js b/backend/src/utils/campuses.js new file mode 100644 index 0000000..8d34d7a --- /dev/null +++ b/backend/src/utils/campuses.js @@ -0,0 +1,138 @@ +/** + * Multi-campus (Site settings → Campuses, off by default). Events belong to one campus, or to + * all campuses when Event.campusId is null. While campuses are off, campus fields and filters + * are ignored everywhere (the data stays, hidden). Campus-scoped staff come in 1.44.0. + */ + +const prisma = require('../config/db'); +const { getSetting } = require('./settingsCache'); + +const SETTING_KEY = 'campuses_enabled'; + +// What the public site may see of a campus. +const CAMPUS_PUBLIC_SELECT = { id: true, name: true, slug: true, address: true, colour: true, logoUrl: true, defaultLocation: true, contactName: true, contactEmail: true, contactPhone: true, order: true }; +// What an event carries about its campus (cards, tickets, lists). +const EVENT_CAMPUS_SELECT = { id: true, name: true, slug: true, colour: true }; + +async function campusesEnabled() { + return String(await getSetting(SETTING_KEY, 'false')).toLowerCase() === 'true'; +} + +/** "Hope Centre — Henley" → "hope-centre-henley". */ +function slugify(text) { + return String(text || '') + .normalize('NFKD').replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); +} + +const trimOrNull = (v, max = 200) => { + if (v === undefined) return undefined; + const s = String(v ?? '').trim(); + return s ? s.slice(0, max) : null; +}; + +/** + * Check and clean the fields of a campus from a request body. Returns { data } or { error }. + * `partial` (an update) only touches the fields that were sent. + */ +function cleanCampusInput(body = {}, { partial = false } = {}) { + const data = {}; + if (!partial || body.name !== undefined) { + const name = trimOrNull(body.name, 100); + if (!name) return { error: 'Give the campus a name.' }; + data.name = name; + } + if (body.slug !== undefined || (!partial && data.name)) { + const slug = slugify(body.slug || data.name); + if (!slug) return { error: 'The web address needs letters or numbers.' }; + data.slug = slug; + } + for (const [key, max] of [['address', 300], ['contactName', 100], ['contactPhone', 40], ['defaultLocation', 300], ['logoUrl', 500]]) { + const v = trimOrNull(body[key], max); + if (v !== undefined) data[key] = v; + } + if (body.contactEmail !== undefined) { + const email = trimOrNull(body.contactEmail, 200); + if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { error: 'That contact email doesn\'t look right.' }; + data.contactEmail = email; + } + if (body.colour !== undefined) { + const colour = trimOrNull(body.colour, 7); + if (colour && !/^#[0-9a-fA-F]{6}$/.test(colour)) return { error: 'Use a colour like #1d4ed8.' }; + data.colour = colour ? colour.toLowerCase() : null; + } + return { data }; +} + +/** + * The `where` for a campus filter on a list of events. + * filter ''/undefined → no filter (church-wide) + * filter 'all' → only events for all campuses + * filter → that campus's events; `withAllCampuses` also includes events for all + * campuses (the public list: a church-wide camp shows at every campus) + * Returns {} when campuses are off, so callers can spread it unconditionally. + */ +function campusWhere(filter, { enabled, withAllCampuses = false } = {}) { + if (!enabled || !filter) return {}; + if (filter === 'all') return { campusId: null }; + return withAllCampuses ? { OR: [{ campusId: String(filter) }, { campusId: null }] } : { campusId: String(filter) }; +} + +/** A campus by id or slug (archived ones too), or null. */ +async function findCampus(idOrSlug) { + if (!idOrSlug) return null; + const v = String(idOrSlug); + return prisma.campus.findFirst({ where: { OR: [{ id: v }, { slug: v }] } }); +} + +/** Resolve a filter value from a query string (id, slug or 'all') to what campusWhere takes. */ +async function resolveCampusFilter(value) { + if (!value) return ''; + if (value === 'all') return 'all'; + const campus = await findCampus(value); + // An unknown campus matches nothing rather than silently showing everything. + return campus ? campus.id : '__none__'; +} + +/** + * The campus to save on an event from a request's `campusId` (''/null = all campuses). + * Returns { campusId } (undefined = leave as is) or { error }. Ignored while campuses are off. + * An archived campus can stay on an event that already has it, but can't be newly chosen. + */ +async function campusForEvent(value, { enabled, currentCampusId = null } = {}) { + if (!enabled || value === undefined) return { campusId: undefined }; + if (value === null || value === '' || value === 'all') return { campusId: null }; + const campus = await prisma.campus.findUnique({ where: { id: String(value) } }); + if (!campus) return { error: 'That campus doesn\'t exist.' }; + if (campus.archivedAt && campus.id !== currentCampusId) return { error: `${campus.name} is archived — choose another campus.` }; + return { campusId: campus.id }; +} + +const AUDIENCE_MONTHS = 12; + +/** + * The `where` for "everyone at campus X" (broadcasts): people whose home campus is X, plus — + * with `includeRegistrants` — people with a registration for one of X's events in the last + * 12 months. Active real accounts only. News consent is still checked when sending. + */ +function campusAudienceWhere(campusId, { includeRegistrants = false, now = new Date() } = {}) { + const since = new Date(now); + since.setMonth(since.getMonth() - AUDIENCE_MONTHS); + const who = [{ homeCampusId: campusId }]; + if (includeRegistrants) { + who.push({ registrations: { some: { createdAt: { gte: since }, status: { not: 'cancelled' }, event: { campusId } } } }); + } + return { + isActive: true, + NOT: [{ email: { endsWith: '@deleted.invalid' } }, { email: { endsWith: '@guest.local' } }], + OR: who, + }; +} + +module.exports = { + SETTING_KEY, AUDIENCE_MONTHS, CAMPUS_PUBLIC_SELECT, EVENT_CAMPUS_SELECT, + campusesEnabled, slugify, cleanCampusInput, campusWhere, findCampus, resolveCampusFilter, campusForEvent, campusAudienceWhere, +}; diff --git a/backend/src/utils/notifications.js b/backend/src/utils/notifications.js index 8d3c8da..a7d79d1 100644 --- a/backend/src/utils/notifications.js +++ b/backend/src/utils/notifications.js @@ -1422,7 +1422,13 @@ async function sendReceiptToUser(paymentId, requesterId, channel) { async function sendDailyEventSummaries(now = new Date()) { try { const today = new Date(now); - const notifyInclude = { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } }; + // Campus summary recipients (Site settings → Campuses) also get their campus's events. + const campusesOn = await require('./campuses').campusesEnabled(); + const notifyInclude = { + createdBy: { select: { id: true, name: true, email: true } }, + notifyRecipients: { select: { id: true, name: true, email: true } }, + ...(campusesOn ? { campus: { select: { notifyRecipients: { select: { email: true } } } } } : {}), + }; let events = await prisma.event.findMany({ where: { isActive: true, startDate: { gte: today }, requiresRegistration: true }, include: notifyInclude, @@ -1462,6 +1468,7 @@ async function sendDailyEventSummaries(now = new Date()) { : 0; const { subject, text, html } = buildDailySummary(ev, registrations, payments, now, waitlistCount); const to = buildEventNotifyRecipientList(ev); + for (const u of ev.campus?.notifyRecipients || []) if (u.email && !to.includes(u.email)) to.push(u.email); if (to.length) await sendMail({ to: to.join(','), subject, html, text }); })); } catch (e) { diff --git a/backend/src/utils/privacy.js b/backend/src/utils/privacy.js index 61b6b29..92b8aca 100644 --- a/backend/src/utils/privacy.js +++ b/backend/src/utils/privacy.js @@ -404,6 +404,7 @@ async function anonymiseUser(userId, db = prisma) { tokenVersion: { increment: 1 }, newsEmail: false, newsWhatsApp: false, + homeCampusId: null, }, }); diff --git a/backend/tests/campuses.test.js b/backend/tests/campuses.test.js new file mode 100644 index 0000000..7e30498 --- /dev/null +++ b/backend/tests/campuses.test.js @@ -0,0 +1,80 @@ +const { slugify, cleanCampusInput, campusWhere, campusAudienceWhere, campusForEvent } = require('../src/utils/campuses'); + +describe('campus web addresses', () => { + test.each([ + ['Hope Centre — Henley', 'hope-centre-henley'], + [' Vaal Campus ', 'vaal-campus'], + ['Ēast Rand!!', 'east-rand'], + ['---', ''], + ])('%s → %s', (input, slug) => expect(slugify(input)).toBe(slug)); +}); + +describe('campus fields', () => { + test('a new campus needs a name; the slug comes from it', () => { + expect(cleanCampusInput({}).error).toMatch(/name/); + expect(cleanCampusInput({ name: 'Henley Campus' }).data).toMatchObject({ name: 'Henley Campus', slug: 'henley-campus' }); + }); + + test('a chosen slug is cleaned', () => { + expect(cleanCampusInput({ name: 'Henley', slug: 'Henley On Klip' }).data.slug).toBe('henley-on-klip'); + }); + + test('bad colour or email is refused; blanks become null', () => { + expect(cleanCampusInput({ name: 'A', colour: 'blue' }).error).toMatch(/colour/); + expect(cleanCampusInput({ name: 'A', contactEmail: 'nope' }).error).toMatch(/email/); + expect(cleanCampusInput({ name: 'A', colour: '#1D4ED8', address: ' ' }).data).toMatchObject({ colour: '#1d4ed8', address: null }); + }); + + test('an update only touches what was sent', () => { + expect(cleanCampusInput({ address: '1 Church St' }, { partial: true }).data).toEqual({ address: '1 Church St' }); + expect(cleanCampusInput({ name: '' }, { partial: true }).error).toMatch(/name/); + }); +}); + +describe('campus filters on event lists', () => { + test('ignored while campuses are off, or with no filter', () => { + expect(campusWhere('c1', { enabled: false })).toEqual({}); + expect(campusWhere('', { enabled: true })).toEqual({}); + }); + + test('one campus, or only church-wide events', () => { + expect(campusWhere('c1', { enabled: true })).toEqual({ campusId: 'c1' }); + expect(campusWhere('all', { enabled: true })).toEqual({ campusId: null }); + }); + + test('the public list also shows church-wide events at every campus', () => { + expect(campusWhere('c1', { enabled: true, withAllCampuses: true })).toEqual({ OR: [{ campusId: 'c1' }, { campusId: null }] }); + }); +}); + +describe('the campus on an event', () => { + test('nothing changes while campuses are off or nothing was sent', async () => { + expect(await campusForEvent('c1', { enabled: false })).toEqual({ campusId: undefined }); + expect(await campusForEvent(undefined, { enabled: true })).toEqual({ campusId: undefined }); + }); + + test('blank or "all" means all campuses', async () => { + expect(await campusForEvent('', { enabled: true })).toEqual({ campusId: null }); + expect(await campusForEvent(null, { enabled: true })).toEqual({ campusId: null }); + expect(await campusForEvent('all', { enabled: true })).toEqual({ campusId: null }); + }); +}); + +describe('everyone at a campus', () => { + const now = new Date('2026-10-27T10:00:00Z'); + + test('home campus only, active real accounts', () => { + const where = campusAudienceWhere('c1', { now }); + expect(where.isActive).toBe(true); + expect(where.OR).toEqual([{ homeCampusId: 'c1' }]); + expect(where.NOT).toEqual(expect.arrayContaining([{ email: { endsWith: '@deleted.invalid' } }, { email: { endsWith: '@guest.local' } }])); + }); + + test('with registrants: registered for that campus\'s events in the last 12 months, not cancelled', () => { + const where = campusAudienceWhere('c1', { includeRegistrants: true, now }); + const reg = where.OR[1].registrations.some; + expect(reg.event).toEqual({ campusId: 'c1' }); + expect(reg.status).toEqual({ not: 'cancelled' }); + expect(reg.createdAt.gte.toISOString().slice(0, 10)).toBe('2025-10-27'); + }); +});