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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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=<slug or id>: 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=<id, slug or 'all'>: one campus's events, or only church-wide ones.
|
||||
const enabled = await campusesEnabled();
|
||||
Object.assign(where, campusWhere(enabled ? await resolveCampusFilter(req.query.campus) : '', { enabled }));
|
||||
|
||||
const 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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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=<id|slug|all> (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,
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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 <campus id> → 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,
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -404,6 +404,7 @@ async function anonymiseUser(userId, db = prisma) {
|
||||
tokenVersion: { increment: 1 },
|
||||
newsEmail: false,
|
||||
newsWhatsApp: false,
|
||||
homeCampusId: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user