Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f36e84fd90 | ||
|
|
a6909ce506 | ||
|
|
955f8d9831 | ||
|
|
2727fc6554 |
@@ -7,6 +7,39 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.43.0] - 2026-10-27
|
||||
|
||||
### Added
|
||||
|
||||
- **Campuses** (Site settings → Campuses, off by default) for a church with more than one campus
|
||||
in one install. Churches with one campus see no campus fields anywhere.
|
||||
- Admins turn campuses on, then add, order, archive and delete campuses: name, web address,
|
||||
address, contact, colour, logo, default event location, and staff who get that campus's daily
|
||||
summary. A campus with events can't be deleted, only archived. Turning campuses off hides
|
||||
everything campus-related and deletes nothing.
|
||||
- **Events** belong to one campus or to **All campuses** (church-wide). Choosing a campus fills
|
||||
in its default location; moving an event to another campus is in the audit log. The Events
|
||||
list in the dashboard shows each event's campus and has a campus filter.
|
||||
- **Public site:** a campus filter on the events page, remembered in the visitor's browser and
|
||||
starting on their home campus; a campus shows its own events plus church-wide ones. The campus
|
||||
shows on event cards, the event page and ticket PDFs. **Campus pages** at `/campus/<slug>`
|
||||
(also in the sitemap).
|
||||
- **Home campus** on the profile page.
|
||||
- **Reports and dashboards:** a campus filter on Reports (all reports and the funnel) and on the
|
||||
admin and supervisor dashboards' past-month tiles, with a church-wide total.
|
||||
- **Broadcasts:** "Everyone at a campus" in Email and WhatsApp broadcasts — people whose home
|
||||
campus it is, plus (ticked) people who registered for its events in the last 12 months. News
|
||||
consent still applies.
|
||||
- **Daily summary:** campus recipients also get the summary of their campus's events.
|
||||
- The Hub heartbeat reports whether campuses are on and how many there are (`campusCount`).
|
||||
- Campus-scoped staff (supervisors and staff limited to some campuses) follow in 1.44.0.
|
||||
- The privacy notice lists the new `hope_events_campus` browser-storage item.
|
||||
|
||||
### Fixed
|
||||
|
||||
- "Request update" (1.42.0) wasn't written to the audit log: the `hub_update_requested` action was
|
||||
missing from the database. The request itself always worked.
|
||||
|
||||
## [1.42.1] - 2026-10-27
|
||||
|
||||
### Changed
|
||||
|
||||
+31
-6
@@ -29,12 +29,13 @@ WhatsApp · PM2 for process management · optional Sentry for error monitoring.
|
||||
13. [Cashup & event costs](#cashup--event-costs)
|
||||
14. [Reports](#reports)
|
||||
15. [Site settings & setup wizard](#site-settings--setup-wizard)
|
||||
16. [Accounts & security](#accounts--security)
|
||||
17. [Privacy (POPIA)](#privacy-popia)
|
||||
18. [Help system](#help-system)
|
||||
19. [Background jobs](#background-jobs)
|
||||
20. [Operations](#operations)
|
||||
21. [Page map](#page-map)
|
||||
16. [Multi-campus](#multi-campus)
|
||||
17. [Accounts & security](#accounts--security)
|
||||
18. [Privacy (POPIA)](#privacy-popia)
|
||||
19. [Help system](#help-system)
|
||||
20. [Background jobs](#background-jobs)
|
||||
21. [Operations](#operations)
|
||||
22. [Page map](#page-map)
|
||||
|
||||
---
|
||||
|
||||
@@ -511,6 +512,7 @@ applied without a restart. Secrets are encrypted with AES-256-GCM.
|
||||
| Tab | Contains |
|
||||
|-----|----------|
|
||||
| Organisation | Name, tagline, contact email/phone, address (with map link) |
|
||||
| Campuses | Off by default. Turn on, then add, order, archive or delete campuses (name, web address, address, contact, colour, logo, default event location, daily-summary recipients) |
|
||||
| Branding | Primary/secondary/accent colours, logo, favicon, colours suggested from the logo, live website/email preview on desktop and mobile |
|
||||
| Notifications | Registrations inbox and admin notification emails |
|
||||
| Email | SMTP host/port/TLS/user/password/from, with a "Test connection" button |
|
||||
@@ -523,6 +525,28 @@ See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
|
||||
|
||||
---
|
||||
|
||||
## Multi-campus
|
||||
|
||||
For a church with several campuses in one install (Site settings → Campuses, off by
|
||||
default — single-campus churches never see campus fields).
|
||||
|
||||
- **Events** belong to one campus or to **All campuses** (church-wide). Choosing a campus fills in
|
||||
its default location. Moving an event to another campus is in the audit log.
|
||||
- **Public site:** a campus filter on the events page, remembered per visitor and starting on their
|
||||
home campus; a campus shows its own events plus church-wide ones. The campus shows on event cards,
|
||||
the event page and ticket PDFs. Each campus has a page at `/campus/<slug>` (also in the sitemap).
|
||||
- **People** choose a home campus on their profile.
|
||||
- **Reports and dashboards:** a campus filter on Reports (every report and the funnel) and on the
|
||||
admin/supervisor dashboards' month-over-month tiles, with a church-wide total.
|
||||
- **Messages:** "Everyone at a campus" in Email and WhatsApp broadcasts — home campus, plus (ticked)
|
||||
people who registered for its events in the last 12 months. News consent still applies.
|
||||
- **Daily summary:** campus recipients get the summary of their campus's events.
|
||||
- **Turning it off** hides everything campus-related; nothing is deleted. A campus with events can't
|
||||
be deleted, only archived.
|
||||
- Campus-scoped staff (limited to some campuses) come in 1.44.0.
|
||||
|
||||
---
|
||||
|
||||
## Accounts & security
|
||||
|
||||
- Register, log in, forgot/reset password, activate account (for accounts
|
||||
@@ -689,6 +713,7 @@ can't start, the same jobs run on in-process timers.
|
||||
|------|-----|------|
|
||||
| `/` | Public | Home |
|
||||
| `/events`, `/events/[id]` | Public | Event list and detail |
|
||||
| `/campus/[slug]` | Public | A campus's page and its events (while campuses are on) |
|
||||
| `/[alias]` | Public | Event short link |
|
||||
| `/register/[eventId]` | Public / User | Registration |
|
||||
| `/registration/success` | Public / User | Confirmation, form and pay |
|
||||
|
||||
@@ -85,6 +85,12 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
|
||||
| `consent_medical_retention_days` | `30` | Youth events: medical and emergency details from parental consent forms are deleted this many days after the event ends (daily 09:00 job). The consent record itself — who agreed, when, to which text — is kept. Set under Site Settings → Legal. |
|
||||
| `org_email` (as used on legal pages specifically) | `"privacy@example.com"` | Different fallback than `org_email`'s general default (blank) — also rendered as a `mailto:` link, so it needs to stay a valid-looking address. |
|
||||
|
||||
## Campuses
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `campuses_enabled` | `false` | Site settings → Campuses → "Use campuses". Changed only through `PUT /api/campuses/settings` (audited); hidden from the generic settings endpoints. Off hides campus fields and filters everywhere; campuses and each event's campus are kept. The campuses themselves are in the `Campus` table. |
|
||||
|
||||
## Cross Events Hub (licence)
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|
||||
@@ -847,9 +847,33 @@ The connection to the Cross Events Hub (`src/hub/`, protocol in `docs/hub-protoc
|
||||
| GET | `/api/hub/status` | admin | `{ state: active\|grace\|suspended\|revoked\|unlicensed, readOnly, daysLeft, endsAt, graceEndsAt, licence, connected, keyConfigured, hubUrl, lastHeartbeatAt, lastError, version, latestVersion, updateAvailable }` |
|
||||
| POST | `/api/hub/connect` | admin (incl. the `/setup` token) | `{ licenceKey }` — registers with the Hub; stores the install id, encrypted secret and licence token. Hub errors pass through with their `code` (`LICENCE_NOT_FOUND`, `LICENCE_IN_USE`, `LICENCE_REVOKED`, `HUB_UNREACHABLE`, …). Audited as `hub_connected` |
|
||||
| POST | `/api/hub/heartbeat` | admin | Check in now; returns the status |
|
||||
| POST | `/api/hub/update-request` | admin | Ask the Hub for this site to be updated (protocol §3a); the provider is told, nothing runs on the site. Returns the status with `updateRequest: { requestedAt, version }` until the site runs that version. `409 UP_TO_DATE`; audited as `hub_update_requested` |
|
||||
|
||||
**Read-only mode** (licence suspended, revoked, or past its grace period): `403 { code: "LICENCE_READ_ONLY" }` on `POST /api/events`, event options and variants, `POST /api/registrations` (and `/manual`), `PUT /api/registrations/:id/options`, `POST /api/payments`, `/payments/checkout` (`/send`), `/payments/yoco-checkout` (`/send`), `/payments/pay/:id`, `POST /api/waitlist`, `POST /api/coupons`, `POST /api/bundles/event/:id`. Everything else — scanning, exports, refunds, cancellations, privacy requests, settings — keeps working, and payment webhooks for payments already started are still processed.
|
||||
|
||||
## Campuses
|
||||
|
||||
Multi-campus (`src/utils/campuses.js`), off by default. While off, every campus field and filter
|
||||
below is ignored and `GET /api/campuses` returns `{ enabled: false, campuses: [] }`.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/campuses` | public | `{ enabled, campuses }` — active campuses in order |
|
||||
| GET | `/api/campuses/by-slug/:slug` | public | One active campus (its `/campus/<slug>` page); 404 when archived or off |
|
||||
| GET | `/api/campuses/admin` | admin | All campuses incl. archived, with `notifyRecipients` and `_count { events, homeOf }` |
|
||||
| PUT | `/api/campuses/settings` | admin | `{ enabled }` — audited as `campuses_toggled` (the `campuses_enabled` setting can't be changed through `PUT /api/settings`) |
|
||||
| POST | `/api/campuses` | admin | `{ name, slug?, address?, contactName?, contactEmail?, contactPhone?, colour? (#rrggbb), logoUrl?, defaultLocation? }`; 409 when the slug is taken |
|
||||
| PUT | `/api/campuses/:id` | admin | Same fields, plus `notifyRecipientIds` (staff only) and `archived` |
|
||||
| PUT | `/api/campuses/order` | admin | `{ ids: [...] }` |
|
||||
| DELETE | `/api/campuses/:id` | admin | Only without events — otherwise `409 CAMPUS_HAS_EVENTS` (archive instead) |
|
||||
| GET | `/api/campuses/:id/audience` | supervisor+ | `{ campus, users }` — home campus, plus `?registrants=true` people registered for its events in the last 12 months (not cancelled). Broadcasts send to these ids as usual, so news consent still applies |
|
||||
|
||||
**Campus on other endpoints:**
|
||||
- Events carry `campusId` (null = all campuses) and `campus { id, name, slug, colour }`. `POST`/`PUT /api/events` take `campusId` (`''`/`null` = all campuses; an archived campus can stay but can't be newly chosen); moving an event is audited as `event_campus_changed`.
|
||||
- `GET /api/events?campus=<slug|id>` — that campus's events plus church-wide ones. `GET /api/events/all?campus=<id|slug|all>` — one campus, or church-wide only.
|
||||
- `GET /api/stats/overview?campus=<id|slug|all>`.
|
||||
- `PUT /api/users/profile` takes `homeCampusId`; `GET` returns it.
|
||||
|
||||
## Webhooks
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.42.1",
|
||||
"version": "1.43.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.42.1",
|
||||
"version": "1.43.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.42.1",
|
||||
"version": "1.43.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+18
-1
@@ -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);
|
||||
@@ -925,6 +926,22 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:200, desc:'Status, with updateRequest', body:{ updateAvailable:true, updateRequest:{ requestedAt:'2026-10-27T09:00:00.000Z', version:'1.42.0' } }}, { status:409, desc:'Already on the latest version', body:{ code:'UP_TO_DATE', message:'This site already runs the latest version.' }}, { status:502, desc:'Hub unreachable' }]},
|
||||
]},
|
||||
|
||||
{ title: 'Campuses', base: '/api/campuses', endpoints: [
|
||||
{ method:'GET', path:'/api/campuses', auth:'Public', desc:'Whether campuses are on, and the active campuses in order (empty while off)',
|
||||
responses:[{ status:200, desc:'Success', body:{ enabled:true, campuses:[{ id:'c_1', name:'Henley Campus', slug:'henley-campus', address:'1 Church St', colour:'#1d4ed8', defaultLocation:'Main hall', order:0 }] }}]},
|
||||
{ method:'GET', path:'/api/campuses/by-slug/:slug', auth:'Public', desc:'One active campus, for its /campus/<slug> page (404 when archived or campuses are off)' },
|
||||
{ method:'GET', path:'/api/campuses/admin', auth:'admin', desc:'All campuses with archived ones, event and home-campus counts, and daily-summary recipients' },
|
||||
{ method:'PUT', path:'/api/campuses/settings', auth:'admin', desc:'Turn campuses on or off (off hides campus fields and filters; nothing is deleted). Audited as campuses_toggled',
|
||||
request:{ body:{ enabled:true } }},
|
||||
{ method:'POST', path:'/api/campuses', auth:'admin', desc:'Add a campus (slug made from the name unless given). 409 if the slug is taken',
|
||||
request:{ body:{ name:'Henley Campus', address:'1 Church St', colour:'#1d4ed8', defaultLocation:'Main hall' } }},
|
||||
{ method:'PUT', path:'/api/campuses/:id', auth:'admin', desc:'Change a campus, its daily-summary recipients (staff only) and archived state',
|
||||
request:{ body:{ name:'Henley Campus', notifyRecipientIds:['u_1'], archived:false } }},
|
||||
{ method:'PUT', path:'/api/campuses/order', auth:'admin', desc:'Put the campuses in this order', request:{ body:{ ids:['c_2','c_1'] } }},
|
||||
{ method:'DELETE', path:'/api/campuses/:id', auth:'admin', desc:'Delete a campus with no events (409 CAMPUS_HAS_EVENTS otherwise — archive it)' },
|
||||
{ method:'GET', path:'/api/campuses/:id/audience', auth:'supervisor+', desc:'"Everyone at campus X" for broadcasts: home campus X, plus with ?registrants=true people registered for its events in the last 12 months' },
|
||||
]},
|
||||
|
||||
{ title: 'Privacy (POPIA)', base: '/api/privacy', endpoints: [
|
||||
{ method:'GET', path:'/api/privacy/me', auth:'user', desc:'My news opt-in, its history, and my privacy requests',
|
||||
responses:[{ status:200, desc:'Success', body:{ news:{ email:true, whatsapp:false }, newsAskedAt:'2026-10-23T08:00:00.000Z', canWhatsApp:false, notificationPreference:'email', history:[{ channel:'email', granted:true, source:'signup', createdAt:'2026-10-23T08:00:00.000Z' }], requests:[] }}]},
|
||||
@@ -1171,7 +1188,7 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'GET', path:'/api/stats/staff', auth:'staff+', desc:'Everything the staff dashboard needs, in one call' },
|
||||
{ method:'GET', path:'/api/stats/supervisor', auth:'supervisor+', desc:'Everything the supervisor dashboard needs, in one call' },
|
||||
{ method:'GET', path:'/api/stats/admin', auth:'admin', desc:'Everything the admin dashboard needs, in one call' },
|
||||
{ method:'GET', path:'/api/stats/overview', auth:'supervisor+', desc:'Trailing-month KPIs (registrations, tickets, revenue, donations), revenue trend, top events, funnel conversion' },
|
||||
{ method:'GET', path:'/api/stats/overview', auth:'supervisor+', desc:'Trailing-month KPIs (registrations, tickets, revenue, donations), revenue trend, top events, funnel conversion. ?campus=<id|slug|all> for one campus or church-wide events only' },
|
||||
]},
|
||||
{ title: 'Backups', base: '/api/backups', endpoints: [
|
||||
{ method:'GET', path:'/api/backups', auth:'admin', desc:'Local database backups, newest first' },
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -182,6 +182,7 @@ const DEFAULT_SECTIONS = [
|
||||
'• hope_events_device — only if you tick "Trust this device" when entering a two-factor code, so you aren\'t asked for a code every time.',
|
||||
'• hope_events_cookie_consent — remembers your choice on the cookie notice.',
|
||||
'• banner_dismissed_… — remembers notices you have closed.',
|
||||
'• hope_events_campus — only on churches with more than one campus: the campus you chose on the events page, so it stays chosen.',
|
||||
'Staff and administrators only:',
|
||||
'• hope_events_scan_device, hope_events_offline_lock and the "hope-events-offline-scan" database — on a staff member\'s scanner in offline mode: the device\'s id, the ticket list for the section being scanned, and scans waiting to upload. They are deleted when offline mode is ended. A copy of the scanner page is also cached so it opens without a connection.',
|
||||
'• hope_events_admin_token, hope_events_admin_user_cache and hope_events_impersonation — only while an administrator is using read-only support access (section 9), and removed when it ends.',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
+23
-2
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-27, after **1.42.1** (and Hub 1.1.0). The plan itself is in
|
||||
Last updated: 2026-10-27, after **1.43.0** (and Hub 1.1.0). The plan itself is in
|
||||
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
|
||||
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
@@ -14,6 +14,7 @@ encrypted settings unreadable or break existing installs.
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 1.43.0 | **Campuses** (§12 part 1): Site settings → Campuses (off by default), events per campus or all campuses, public campus filter (remembered, starts on home campus) and `/campus/<slug>` pages, home campus on the profile, campus filters on Reports and the dashboards, "Everyone at a campus" in broadcasts, campus summary recipients, campus on ticket PDFs, heartbeat `campusCount`. Also adds the `hub_update_requested` audit action missing since 1.42.0 |
|
||||
| 1.42.1 | Production Hub public key built in (`publicKey.js`); `HUB_PUBLIC_KEY` only needed for a local Hub |
|
||||
| 1.42.0 | **Request update** instead of the deploy command: Site settings → Licence sends a signed update request to the Hub (protocol §3a), which opens an alert and emails Joshua; "Update requested on …" until the site runs that version, and the admin update banner hides meanwhile. Audit action `hub_update_requested`. Needs Hub 1.1.0 |
|
||||
| 1.41.3 | Renamed Hope Events → **Cross Events** in all UI text and docs (technical identifiers unchanged, see above) |
|
||||
@@ -42,7 +43,10 @@ encrypted settings unreadable or break existing installs.
|
||||
request). A 1.42.0 site talking to an older Hub shows "The Hub doesn't take update requests yet".
|
||||
2. **Try `scripts/install.sh` on a test server.** The production Hub key is built in since
|
||||
1.42.1, so a fresh install needs no `HUB_PUBLIC_KEY`; existing installs can drop it from `.env`.
|
||||
3. **1.43.0 — Multi-campus** (§12).
|
||||
3. **1.44.0 — Campus-scoped staff** (§12, "Plumbing (1.44.0)"): `UserCampusScope`, a
|
||||
`utils/campusScope.js` helper on every staff/supervisor endpoint (115 in 27 route files at
|
||||
1.42.1), and the route-list test that fails on any endpoint that isn't campus-checked or marked
|
||||
campus-independent.
|
||||
|
||||
## Working on this repo
|
||||
|
||||
@@ -191,6 +195,23 @@ encrypted settings unreadable or break existing installs.
|
||||
- `scripts/install.sh` — fresh Ubuntu server to a running site (written and syntax-checked; not yet
|
||||
run on a real server).
|
||||
|
||||
## Where campuses live (1.43.0)
|
||||
|
||||
- `backend/src/utils/campuses.js`: the setting (`campuses_enabled`, off by default), `campusWhere`
|
||||
(list filters: one campus, `'all'` = church-wide only, `withAllCampuses` for the public list),
|
||||
`campusForEvent` (what to save on an event), `campusAudienceWhere` (broadcast audience). Pure
|
||||
parts are tested in `tests/campuses.test.js`.
|
||||
- `controllers/campusController.js`, routes `/api/campuses/*`. The setting is changed only through
|
||||
`PUT /api/campuses/settings` (it's in `PRIVATE_KEYS` in the settings controller).
|
||||
- **While campuses are off, every campus field and filter is ignored** (the data stays). New code
|
||||
should check `campusesEnabled()` the same way.
|
||||
- Frontend: `hooks/useCampuses.ts` (one cached `GET /api/campuses` per page load),
|
||||
`components/shared/CampusFilter.tsx` (`CampusFilter`, `matchesCampus`, `CampusBadge`),
|
||||
`CampusAudiencePicker.tsx`, `components/admin/CampusesTab.tsx`, `components/events/EventsGrid.tsx`
|
||||
(public filter, `hope_events_campus` in localStorage), `app/campus/[slug]/page.tsx`.
|
||||
- e2e: the campuses and two events' campuses are **seeded** in `e2e/support/prepare-db.js` with
|
||||
campuses off (so a pre-built frontend already has them); `campuses.spec.ts` turns them on and off.
|
||||
|
||||
## Known gaps / follow-ups
|
||||
|
||||
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
|
||||
|
||||
+4
-3
@@ -27,8 +27,9 @@ notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (
|
||||
Cross Events Hub); 1.42.0 **Request update** button instead of the deploy command, with Hub 1.1.0
|
||||
(update requests); 1.42.1 the production Hub key built in. Later versions moved up by one
|
||||
(2026-10-27). Multi-campus is split in two — 1.43.0 campuses, 1.44.0 campus-scoped staff — so
|
||||
everything after moves up by one more (2026-10-27).
|
||||
**Next: 1.43.0 Campuses.**
|
||||
everything after moves up by one more (2026-10-27). 1.43.0 **campuses** shipped (settings, events
|
||||
per campus, public filter and campus pages, home campus, filters, campus broadcasts and summaries).
|
||||
**Next: 1.44.0 Campus-scoped staff.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -909,7 +910,7 @@ income justify the hardware:
|
||||
| 4 | 1.36.0 ✅ – 1.37.2 | Payment provider interface (Yoco moved behind it) → PayFast, then hiding "Pay online" when it can't be used and payment status checks (SnapScan and Zapper dropped) | Payment code gets split out of the big controller as part of this |
|
||||
| 5 | 1.38.0 ✅ 1.39.0 ✅ 1.40.0 ✅ | **POPIA toolkit** (1.38.0: user privacy centre, unsubscribe, WhatsApp STOP, data requests inbox; 1.39.0: privacy notice editor + versions, operator register, consent log, access log; 1.40.0: retention jobs, breach register, children’s-data flags, readiness page) | Main selling point; retention jobs use the job queue |
|
||||
| 6 | 1.41.0 ✅ – 1.42.0 ✅ + Hub 1.0 ✅ – 1.1 ✅ | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo); rename to Cross Events (1.41.3); Request update (1.42.0 / Hub 1.1.0) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
|
||||
| 7 | 1.43.0 – 1.44.0 | **Multi-campus**: 1.43.0 campuses (settings, events, public filter and landing pages, home campus, filters, messages, summaries); 1.44.0 campus-scoped staff | Kids rooms and volunteer roles are per campus, so campuses come first; scoping is split out because it's security-heavy |
|
||||
| 7 | 1.43.0 ✅ – 1.44.0 | **Multi-campus**: 1.43.0 campuses (settings, events, public filter and landing pages, home campus, filters, messages, summaries); 1.44.0 campus-scoped staff | Kids rooms and volunteer roles are per campus, so campuses come first; scoping is split out because it's security-heavy |
|
||||
| 8 | 1.45.0 – 1.46.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
|
||||
| 9 | 1.47.0 – 1.49.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
|
||||
| 10 | 1.50.0 – 1.52.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
|
||||
|
||||
@@ -96,7 +96,8 @@ X-Hub-Signature: <hex HMAC-SHA256(installSecret, timestamp + "." + rawBody)>
|
||||
"checkIns": { "last30d": 96 },
|
||||
"messages": { "email30d": 610, "whatsapp30d": 233 },
|
||||
"paymentProviders": ["yoco", "payfast"],
|
||||
"campuses": false,
|
||||
"campuses": false, // Site settings → Campuses on/off
|
||||
"campusCount": 0, // active campuses (0 while off; Cross Events 1.43.0+)
|
||||
"income": { "last30d": "R5 001–R20 000", "allTime": "over R100 000" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test, expect, seed, api, API } from "./fixtures";
|
||||
|
||||
// Multi-campus. The campuses and the two events' campuses are seeded (e2e/support/prepare-db.js)
|
||||
// with campuses off; these tests turn them on, and off again afterwards so the other specs see a
|
||||
// single-campus church.
|
||||
test.describe("campuses", () => {
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const page = await browser.newPage();
|
||||
await api(page, "admin", "PUT", "/api/campuses/settings", { enabled: true });
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
const page = await browser.newPage();
|
||||
await api(page, "admin", "PUT", "/api/campuses/settings", { enabled: false });
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test("the events page filters by campus and keeps church-wide events", async ({ page }) => {
|
||||
await page.goto("/events");
|
||||
await expect(page.getByText("E2E Paid Conference")).toBeVisible();
|
||||
await page.getByLabel("Campus").selectOption({ label: "E2E North" });
|
||||
await expect(page.getByText("E2E Free Evening")).toBeVisible(); // North
|
||||
await expect(page.getByText("E2E Alpha Course")).toBeVisible(); // all campuses
|
||||
await expect(page.getByText("E2E Paid Conference")).toBeHidden(); // South
|
||||
// The choice is remembered.
|
||||
await page.reload();
|
||||
await expect(page.getByLabel("Campus")).toHaveValue(seed().campuses.north.id);
|
||||
await expect(page.getByText("E2E Paid Conference")).toBeHidden();
|
||||
});
|
||||
|
||||
test("a campus has its own page", async ({ page }) => {
|
||||
await page.goto(`/campus/${seed().campuses.north.slug}`);
|
||||
await expect(page.getByRole("heading", { name: "E2E North" })).toBeVisible();
|
||||
await expect(page.getByText("1 North Road")).toBeVisible();
|
||||
await expect(page.getByText("E2E Free Evening")).toBeVisible();
|
||||
await expect(page.getByText("E2E Paid Conference")).toBeHidden();
|
||||
});
|
||||
|
||||
test("a campus with events can't be deleted", async ({ page }) => {
|
||||
const res = await page.request.delete(`${API}/api/campuses/${seed().campuses.north.id}`, { headers: { Authorization: `Bearer ${seed().users.admin.token}` } });
|
||||
expect(res.status()).toBe(409);
|
||||
expect((await res.json()).code).toBe("CAMPUS_HAS_EVENTS");
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ export type Role = "admin" | "supervisor" | "staff" | "user";
|
||||
export type Seed = {
|
||||
users: Record<Role, { id: string; name: string; email: string; token: string }>;
|
||||
events: { free: string; paid: string; course: string; dropIn: string; conference: string };
|
||||
campuses: Record<"north" | "south", { id: string; slug: string }>;
|
||||
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
|
||||
unsubscribeToken: string;
|
||||
newsPromptUser: { id: string; token: string };
|
||||
|
||||
@@ -68,8 +68,14 @@ async function seed(url) {
|
||||
return d;
|
||||
};
|
||||
|
||||
// Multi-campus: two campuses with campuses switched off (campuses.spec.ts turns them on).
|
||||
// Seeded here rather than in the test so pages built before the tests already carry them.
|
||||
const north = await prisma.campus.create({ data: { name: 'E2E North', slug: 'e2e-north', address: '1 North Road', order: 0 } });
|
||||
const south = await prisma.campus.create({ data: { name: 'E2E South', slug: 'e2e-south', order: 1 } });
|
||||
|
||||
const free = await prisma.event.create({
|
||||
data: {
|
||||
campusId: north.id,
|
||||
title: 'E2E Free Evening', description: 'A free evening for the tests.', startDate: at(1), endDate: at(1, 19),
|
||||
price: 0, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
|
||||
eventOptions: { create: [{ name: 'Entry', price: 0, isMainTicket: true }] },
|
||||
@@ -77,6 +83,7 @@ async function seed(url) {
|
||||
});
|
||||
const paid = await prisma.event.create({
|
||||
data: {
|
||||
campusId: south.id,
|
||||
title: 'E2E Paid Conference', description: 'A paid one-day conference.', startDate: at(3, 7), endDate: at(3, 15),
|
||||
price: 150, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
|
||||
eventOptions: { create: [{ name: 'Conference ticket', price: 150, isMainTicket: true }] },
|
||||
@@ -156,6 +163,7 @@ async function seed(url) {
|
||||
const out = {
|
||||
users: Object.fromEntries(Object.entries(users).map(([k, u]) => [k, { id: u.id, name: u.name, email: u.email, token: generateToken(u.id, u.role, u.tokenVersion || 0) }])),
|
||||
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id, conference: conference.id },
|
||||
campuses: { north: { id: north.id, slug: north.slug }, south: { id: south.id, slug: south.slug } },
|
||||
course: { sessions: course.sessions.map(s => ({ id: s.id, label: s.label })), ticketId: ticket.id, qrCode: ticket.qrCode },
|
||||
// An account that has never answered the news question (asked once after login).
|
||||
newsPromptUser: await (async () => {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.42.1",
|
||||
"version": "1.43.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.42.1",
|
||||
"version": "1.43.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.42.1",
|
||||
"version": "1.43.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { EventCard } from "@/components/events/EventCard";
|
||||
import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { apiFetch, apiFetchListForPrerender, ApiError } from "@/lib/api";
|
||||
import { mapsSearchUrl } from "@/lib/maps";
|
||||
import { CalendarX, MapPin, Mail, Phone, User, ArrowLeft } from "lucide-react";
|
||||
import type { Campus } from "@/hooks/useCampuses";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
type Event = React.ComponentProps<typeof EventCard>["event"] & { cashupStatus?: string; nextSession?: { startsAt: string } | null };
|
||||
|
||||
async function loadCampus(slug: string): Promise<Campus | null> {
|
||||
try {
|
||||
return await apiFetch<Campus>(`/api/campuses/by-slug/${encodeURIComponent(slug)}`, { nextOptions: { next: { revalidate } } });
|
||||
} catch (e) {
|
||||
// 404: no such campus, archived, or campuses are off.
|
||||
if (e instanceof ApiError && e.status === 404) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const campus = await loadCampus(slug);
|
||||
if (!campus) return {};
|
||||
return {
|
||||
title: `${campus.name} — events`,
|
||||
description: `Upcoming events at ${campus.name}${campus.address ? `, ${campus.address}` : ""}.`,
|
||||
alternates: { canonical: `/campus/${campus.slug}` },
|
||||
};
|
||||
}
|
||||
|
||||
/** A campus's own page: its details and upcoming events (its own plus church-wide ones). */
|
||||
export default async function CampusPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
const campus = await loadCampus(slug);
|
||||
if (!campus) notFound();
|
||||
|
||||
const events = await apiFetchListForPrerender<Event>(`/api/events?campus=${encodeURIComponent(campus.slug)}`, { nextOptions: { next: { revalidate } } });
|
||||
const now = Date.now();
|
||||
const upcoming = (events || [])
|
||||
.filter(e => e.cashupStatus !== "closed" && new Date(e.endDate || e.startDate).getTime() > now)
|
||||
.sort((a, b) => new Date(a.nextSession?.startsAt || a.startDate).getTime() - new Date(b.nextSession?.startsAt || b.startDate).getTime());
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1 py-10 px-4 max-w-7xl mx-auto w-full">
|
||||
<Link href="/events" className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-4">
|
||||
<ArrowLeft className="w-4 h-4" /> All events
|
||||
</Link>
|
||||
<div className="border rounded-2xl p-6 mb-8 bg-white shadow-sm" style={campus.colour ? { borderTop: `4px solid ${campus.colour}` } : undefined}>
|
||||
<div className="flex items-start gap-4">
|
||||
{campus.logoUrl && <ApiImage src={campus.logoUrl} alt="" className="w-16 h-16 object-contain rounded-lg shrink-0" />}
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">{campus.name}</h1>
|
||||
<div className="mt-2 space-y-1 text-sm text-gray-600">
|
||||
{campus.address && (
|
||||
<p className="flex items-center gap-1.5">
|
||||
<MapPin className="w-4 h-4 shrink-0" />
|
||||
<a href={mapsSearchUrl(campus.address)} target="_blank" rel="noopener noreferrer" className="hover:underline">{campus.address}</a>
|
||||
</p>
|
||||
)}
|
||||
{campus.contactName && <p className="flex items-center gap-1.5"><User className="w-4 h-4 shrink-0" /> {campus.contactName}</p>}
|
||||
{campus.contactEmail && <p className="flex items-center gap-1.5"><Mail className="w-4 h-4 shrink-0" /> <a href={`mailto:${campus.contactEmail}`} className="hover:underline">{campus.contactEmail}</a></p>}
|
||||
{campus.contactPhone && <p className="flex items-center gap-1.5"><Phone className="w-4 h-4 shrink-0" /> <a href={`tel:${campus.contactPhone}`} className="hover:underline">{campus.contactPhone}</a></p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-1">Upcoming events</h2>
|
||||
<p className="text-sm text-gray-500 mb-6">Events at {campus.name}, and church-wide events for every campus.</p>
|
||||
{upcoming.length === 0 ? (
|
||||
<div className="text-center py-16 border rounded-xl bg-gray-50">
|
||||
<CalendarX className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||
<p className="text-gray-500 text-sm">No upcoming events at {campus.name} right now — check back soon.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-3 sm:grid-cols-2">
|
||||
{upcoming.map(event => <EventCard key={event.id} event={event} />)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -97,6 +97,12 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
breach_updated: "Security compromise updated",
|
||||
hub_connected: "Connected to the Cross Events Hub",
|
||||
hub_update_requested: "Asked the Hub for an update",
|
||||
campuses_toggled: "Campuses turned on/off",
|
||||
campus_created: "Campus added",
|
||||
campus_updated: "Campus changed",
|
||||
campus_archived: "Campus archived/restored",
|
||||
campus_deleted: "Campus deleted",
|
||||
event_campus_changed: "Event moved to another campus",
|
||||
};
|
||||
|
||||
interface AuditLogEntry {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActio
|
||||
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import type { DashboardStats, PaymentStats } from "@/types";
|
||||
import { CampusFilter } from "@/components/shared/CampusFilter";
|
||||
|
||||
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
|
||||
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
|
||||
@@ -77,6 +78,7 @@ export default function AdminDashboardPage() {
|
||||
const [loadingStats, setLoadingStats] = useState(false);
|
||||
const hasLoadedOnce = useRef(false);
|
||||
const [paymentsTab, setPaymentsTab] = useState<"today" | "week" | "month">("today");
|
||||
const [campus, setCampus] = useState("");
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
if (!token) return;
|
||||
@@ -85,7 +87,8 @@ export default function AdminDashboardPage() {
|
||||
if (isFirstLoad) setLoadingStats(true);
|
||||
const [dash, overviewData] = await Promise.all([
|
||||
apiFetch<DashboardStats>("/api/stats/admin", { authToken: token }),
|
||||
apiFetch<Overview>("/api/stats/overview", { authToken: token }),
|
||||
// The month-over-month tiles follow the campus filter; today's totals stay church-wide.
|
||||
apiFetch<Overview>(`/api/stats/overview${campus ? `?campus=${encodeURIComponent(campus)}` : ""}`, { authToken: token }),
|
||||
]);
|
||||
setPaymentStats(dash.paymentStats);
|
||||
setOverview(overviewData);
|
||||
@@ -95,7 +98,7 @@ export default function AdminDashboardPage() {
|
||||
hasLoadedOnce.current = true;
|
||||
if (isFirstLoad) setLoadingStats(false);
|
||||
}
|
||||
}, [token, setPaymentStats, setOverview]);
|
||||
}, [token, campus, setPaymentStats, setOverview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
@@ -118,6 +121,7 @@ export default function AdminDashboardPage() {
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Here's what's happening with your events today.</p>
|
||||
<CampusFilter value={campus} onChange={setCampus} everyLabel="All campuses (church-wide total)" className="mt-3" id="dashboard-campus" />
|
||||
</div>
|
||||
|
||||
{!isAdmin && (
|
||||
|
||||
@@ -7,22 +7,24 @@ import Link from "next/link";
|
||||
import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, KeyRound, type LucideIcon } from "lucide-react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, KeyRound, MapPinned, type LucideIcon } from "lucide-react";
|
||||
import { ColorPickerField } from "@/components/admin/ColorPickerField";
|
||||
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
||||
import { BackupsTab } from "@/components/admin/BackupsTab";
|
||||
import { JobsTab } from "@/components/admin/JobsTab";
|
||||
import { PaymentsTab } from "@/components/admin/PaymentsTab";
|
||||
import { LicenceTab } from "@/components/admin/LicenceTab";
|
||||
import { CampusesTab } from "@/components/admin/CampusesTab";
|
||||
import { extractDominantColors } from "@/lib/extractColors";
|
||||
import { mapsSearchUrl } from "@/lib/maps";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "backups" | "jobs" | "licence";
|
||||
type TabId = "organisation" | "campuses" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "backups" | "jobs" | "licence";
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "organisation", label: "Organisation", icon: Building2 },
|
||||
{ id: "campuses", label: "Campuses", icon: MapPinned },
|
||||
{ id: "branding", label: "Branding", icon: Palette },
|
||||
{ id: "notifications", label: "Notifications", icon: Bell },
|
||||
{ id: "email", label: "Email", icon: Mail },
|
||||
@@ -716,6 +718,7 @@ function SiteSettingsPageInner() {
|
||||
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
|
||||
{activeTab === "jobs" && <JobsTab active={activeTab === "jobs"} />}
|
||||
{activeTab === "licence" && <LicenceTab active={activeTab === "licence"} />}
|
||||
{activeTab === "campuses" && <CampusesTab active={activeTab === "campuses"} />}
|
||||
</div>
|
||||
|
||||
{activeTab === "branding" && (
|
||||
|
||||
@@ -11,6 +11,7 @@ import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, MessageSendResult, Registration, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { MessageKindPicker, matchedText, type MessageKind } from "@/components/shared/MessageKindPicker";
|
||||
import { CampusAudiencePicker } from "@/components/shared/CampusAudiencePicker";
|
||||
|
||||
type Attendee = { id: string; name: string; email: string; pref: string };
|
||||
type TemplateKey = 'custom'|'payment_reminder'|'event_reminder'|'tickets';
|
||||
@@ -735,6 +736,7 @@ function EmailAttendeesPageInner() {
|
||||
channel="email"
|
||||
/>
|
||||
<div className="text-[11px] text-gray-500 mb-1">Select users from your database to include. Green = Email/both preference. You can also add external emails below.</div>
|
||||
<CampusAudiencePicker selectedIds={selectedUserIds} onChange={setSelectedUserIds} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event (optional for placeholders)</label>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { apiFetch, API_BASE } from "@/lib/api";
|
||||
import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { useCampuses } from "@/hooks/useCampuses";
|
||||
import { CampusBadge, CampusFilter, matchesCampus } from "@/components/shared/CampusFilter";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { ChoicesEditor } from "@/components/shared/FormFieldExtras";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
@@ -830,6 +832,7 @@ interface EventDraft {
|
||||
redirectUrl: string; isActive: boolean; isHidden: boolean; requiresAuth: boolean;
|
||||
requiresRegistration: boolean; contactName: string; contactPhone: string; contactEmail: string;
|
||||
location: string; allowNamedAttendees: boolean;
|
||||
campusId: string; // "" = all campuses
|
||||
requiresParentalConsent: boolean; consentText: string;
|
||||
consentCollectMedical: boolean; consentCollectEmergency: boolean; consentCollectPhoto: boolean;
|
||||
waitlistEnabled: boolean; waitlistOfferHours: string; waitlistPartialOffers: boolean;
|
||||
@@ -845,6 +848,7 @@ const blankDraft = (location = ""): EventDraft => ({
|
||||
redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true,
|
||||
requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "",
|
||||
location, allowNamedAttendees: false,
|
||||
campusId: "",
|
||||
requiresParentalConsent: false, consentText: "",
|
||||
consentCollectMedical: true, consentCollectEmergency: true, consentCollectPhoto: true,
|
||||
waitlistEnabled: false, waitlistOfferHours: "24", waitlistPartialOffers: true,
|
||||
@@ -873,6 +877,7 @@ interface EventModalProps {
|
||||
function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
const { token } = useAuth();
|
||||
const { settings } = useSiteSettings();
|
||||
const campusInfo = useCampuses();
|
||||
const [step, setStep] = useState<StepIdx>(0);
|
||||
const [pricingSubstep, setPricingSubstep] = useState<PricingSubstep>(0);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -897,6 +902,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
allowSponsorships: !!ev.allowSponsorships, sponsorshipGoalSpots: ev.sponsorshipGoalSpots ? String(ev.sponsorshipGoalSpots) : "", showSponsorshipProgress: ev.showSponsorshipProgress !== false,
|
||||
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
|
||||
location: ev.location || "",
|
||||
campusId: ev.campusId || "",
|
||||
scheduleType: ev.scheduleType === "sessions" ? "sessions" : "single",
|
||||
attendanceMode: ev.attendanceMode === "pick" ? "pick" : "whole",
|
||||
} : blankDraft(settings.org_address || ""));
|
||||
@@ -1284,6 +1290,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
contactPhone: draft.contactPhone || undefined,
|
||||
contactEmail: draft.contactEmail || undefined,
|
||||
location: draft.location.trim(),
|
||||
// Only sent while campuses are on; the server ignores it otherwise.
|
||||
...(campusInfo.enabled ? { campusId: draft.campusId || null } : {}),
|
||||
};
|
||||
|
||||
let notice: { eventId: string; moved: number; cancelled: number } | null = null;
|
||||
@@ -1473,6 +1481,29 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
<DTInput label="Registration Deadline (optional)" value={draft.registrationDeadline} onChange={v => upd({ registrationDeadline: v })} hint={multi ? "Registration closes when the first session starts unless late joining is on (Attendance step)" : undefined} />
|
||||
<DTInput label="Go Live At (optional)" value={draft.goLiveAt} onChange={v => upd({ goLiveAt: v })} hint="Leave blank to show immediately" />
|
||||
</div>
|
||||
{campusInfo.enabled && (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="event-campus">Campus</label>
|
||||
<select
|
||||
id="event-campus"
|
||||
className="w-full border rounded px-3 py-2 text-sm bg-white"
|
||||
value={draft.campusId}
|
||||
onChange={e => {
|
||||
const next = campusInfo.campuses.find(c => c.id === e.target.value);
|
||||
const prev = campusInfo.campuses.find(c => c.id === draft.campusId);
|
||||
// Swap in the campus's default location while the field still holds a default.
|
||||
const isDefault = !draft.location.trim() || draft.location === (settings.org_address || "") || draft.location === (prev?.defaultLocation || "");
|
||||
upd({ campusId: e.target.value, ...(next?.defaultLocation && isDefault ? { location: next.defaultLocation } : {}) });
|
||||
}}
|
||||
>
|
||||
<option value="">All campuses</option>
|
||||
{campusInfo.campuses.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
{/* An archived campus stays on events that already have it. */}
|
||||
{ev?.campus && !campusInfo.campuses.some(c => c.id === ev.campus!.id) && <option value={ev.campus.id}>{ev.campus.name} (archived)</option>}
|
||||
</select>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">"All campuses" events show at every campus — for church-wide conferences and camps.</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Location</label>
|
||||
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.location} onChange={e => upd({ location: e.target.value })} placeholder="e.g. 123 Church St, City" />
|
||||
@@ -1844,6 +1875,7 @@ function EventCard({ ev, onEdit }: { ev: Event; onEdit: () => void }) {
|
||||
{isPast && !isInactive && <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">Past</span>}
|
||||
{ev.isHidden && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">Hidden</span>}
|
||||
{isClosed && <span className="text-[10px] px-1.5 py-0.5 rounded bg-rose-50 text-rose-700">Closed</span>}
|
||||
<CampusBadge campus={ev.campus} />
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{scheduleSummary(ev) || <>{ev.startDate ? new Date(ev.startDate).toLocaleDateString() : ""}{ev.endDate ? ` – ${new Date(ev.endDate).toLocaleDateString()}` : ""}</>}
|
||||
@@ -1897,6 +1929,7 @@ export default function ManageEventsPage() {
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const [showClosed, setShowClosed] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [campus, setCampus] = useState("");
|
||||
const isAdmin = user?.role === "admin";
|
||||
const now = Date.now();
|
||||
|
||||
@@ -1905,8 +1938,9 @@ export default function ManageEventsPage() {
|
||||
if (!showClosed && ev.cashupStatus === "closed") return false;
|
||||
if (!includePast && ev.endDate && new Date(ev.endDate).getTime() < now) return false;
|
||||
if (search && !ev.title?.toLowerCase().includes(search.toLowerCase())) return false;
|
||||
if (!matchesCampus(ev, campus)) return false;
|
||||
return true;
|
||||
}), [events, includePast, showInactive, showClosed, search, now]);
|
||||
}), [events, includePast, showInactive, showClosed, search, campus, now]);
|
||||
|
||||
const openCreate = () => { setEditingEvent(null); setModalMode("create"); };
|
||||
const openEdit = async (ev: Event) => {
|
||||
@@ -1940,6 +1974,7 @@ export default function ManageEventsPage() {
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4 text-sm">
|
||||
<input className="border rounded px-3 py-1.5 text-sm w-48" placeholder="Search by title…" value={search} onChange={e => setSearch(e.target.value)} />
|
||||
<CampusFilter value={campus} onChange={setCampus} withChurchWide />
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={includePast} onChange={e => setIncludePast(e.target.checked)} /> Include past
|
||||
</label>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActio
|
||||
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import type { DashboardStats, PaymentStats, RecentScan, ScanStats } from "@/types";
|
||||
import { CampusFilter } from "@/components/shared/CampusFilter";
|
||||
|
||||
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
|
||||
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
|
||||
@@ -75,6 +76,7 @@ export default function SupervisorDashboardPage() {
|
||||
const [overview, setOverview] = useStableState<Overview | null>(null);
|
||||
const [loadingStats, setLoadingStats] = useState(false);
|
||||
const hasLoadedOnce = useRef(false);
|
||||
const [campus, setCampus] = useState("");
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
if (!token) return;
|
||||
@@ -83,7 +85,8 @@ export default function SupervisorDashboardPage() {
|
||||
if (isFirstLoad) setLoadingStats(true);
|
||||
const [data, overviewData] = await Promise.all([
|
||||
apiFetch<DashboardStats>("/api/stats/supervisor", { authToken: token }),
|
||||
apiFetch<Overview>("/api/stats/overview", { authToken: token }),
|
||||
// The month-over-month tiles follow the campus filter; today's totals stay church-wide.
|
||||
apiFetch<Overview>(`/api/stats/overview${campus ? `?campus=${encodeURIComponent(campus)}` : ""}`, { authToken: token }),
|
||||
]);
|
||||
setScanStats(data.scanStats);
|
||||
setPaymentStats(data.paymentStats);
|
||||
@@ -95,7 +98,7 @@ export default function SupervisorDashboardPage() {
|
||||
hasLoadedOnce.current = true;
|
||||
if (isFirstLoad) setLoadingStats(false);
|
||||
}
|
||||
}, [token, setScanStats, setPaymentStats, setRecentScans, setOverview]);
|
||||
}, [token, campus, setScanStats, setPaymentStats, setRecentScans, setOverview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
@@ -114,6 +117,7 @@ export default function SupervisorDashboardPage() {
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Here's what's happening with your events today.</p>
|
||||
<CampusFilter value={campus} onChange={setCampus} everyLabel="All campuses (church-wide total)" className="mt-3" id="dashboard-campus" />
|
||||
</div>
|
||||
|
||||
{!canView && (
|
||||
|
||||
@@ -11,6 +11,7 @@ import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, MessageSendResult, Registration, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { MessageKindPicker, matchedText, type MessageKind } from "@/components/shared/MessageKindPicker";
|
||||
import { CampusAudiencePicker } from "@/components/shared/CampusAudiencePicker";
|
||||
|
||||
// Attendee with preference info
|
||||
type Attendee = { id: string; name: string; phone: string; pref: string };
|
||||
@@ -809,6 +810,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
<label className="block text-xs text-gray-600 mb-1">Users (optional)</label>
|
||||
<AttendeesCheckboxDropdown attendees={users} loading={loadingUsers} selectedIds={selectedUserIds} onChange={setSelectedUserIds} channel="whatsapp" />
|
||||
<div className="text-[11px] text-gray-500 mb-1">Only users with phone numbers shown. Green = WhatsApp/both preference.</div>
|
||||
<CampusAudiencePicker selectedIds={selectedUserIds} onChange={setSelectedUserIds} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event (optional for placeholders)</label>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { TwoFactorChallenge, TwoFactorMethod } from "@/lib/auth";
|
||||
import { User, Lock, ShieldAlert, ShieldCheck, ShieldOff, Trash2, Clock, LogIn, KeyRound, RotateCcw } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { useCampuses } from "@/hooks/useCampuses";
|
||||
|
||||
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset" | "two_factor_enabled" | "two_factor_disabled"; device: string | null; createdAt: string };
|
||||
|
||||
@@ -37,6 +38,8 @@ export default function UserProfilePage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
|
||||
const [homeCampusId, setHomeCampusId] = useState("");
|
||||
const campusInfo = useCampuses();
|
||||
const [profileMsg, setProfileMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
|
||||
@@ -48,6 +51,7 @@ export default function UserProfilePage() {
|
||||
setEmail(user.email || "");
|
||||
setPhone(user.phoneNumber || "");
|
||||
setNotifPref(user.notificationPreference || "email");
|
||||
setHomeCampusId(user.homeCampusId || "");
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
@@ -65,6 +69,7 @@ export default function UserProfilePage() {
|
||||
email,
|
||||
phoneNumber: phone || null,
|
||||
notificationPreference: hasValidPhone ? notifPref : "email",
|
||||
...(campusInfo.enabled ? { homeCampusId: homeCampusId || null } : {}),
|
||||
},
|
||||
});
|
||||
// Backend returns a refreshed token — save it so the session stays valid
|
||||
@@ -335,6 +340,17 @@ export default function UserProfilePage() {
|
||||
<input type="tel" className={inputCls} value={phone} onChange={e => setPhone(e.target.value)} placeholder="e.g. 082 123 4567" />
|
||||
</div>
|
||||
|
||||
{campusInfo.enabled && campusInfo.campuses.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1" htmlFor="home-campus">Home campus</label>
|
||||
<select id="home-campus" className={inputCls} value={homeCampusId} onChange={e => setHomeCampusId(e.target.value)}>
|
||||
<option value="">No home campus</option>
|
||||
{campusInfo.campuses.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">The events page starts on this campus, and you'll get messages meant for people at this campus.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasValidPhone && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notification preference</label>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
|
||||
import { soldOutLines } from "@/lib/waitlist";
|
||||
import { SponsorSpot } from "@/components/events/SponsorSpot";
|
||||
import { FunnelTracker } from "@/components/events/FunnelTracker";
|
||||
import { CampusBadge } from "@/components/shared/CampusFilter";
|
||||
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
|
||||
|
||||
export const revalidate = 60;
|
||||
@@ -45,6 +46,7 @@ type Event = {
|
||||
contactPhone?: string | null;
|
||||
contactEmail?: string | null;
|
||||
location?: string | null;
|
||||
campus?: { id: string; name: string; slug: string; colour: string | null } | null;
|
||||
waitlistEnabled?: boolean;
|
||||
allowSponsorships?: boolean;
|
||||
bundles?: PublicBundle[];
|
||||
@@ -208,6 +210,7 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
|
||||
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{event.title}</h1>
|
||||
<CampusBadge campus={event.campus} link className="mt-2 text-xs" />
|
||||
<p className="text-sm text-gray-500 flex items-center gap-1.5 mt-2">
|
||||
<Calendar className="w-4 h-4 shrink-0" />
|
||||
{scheduleSummary(event) || formatDateTimeRange(event.startDate, event.endDate)}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Metadata } from "next";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { EventCard } from "@/components/events/EventCard";
|
||||
import { EventsGrid } from "@/components/events/EventsGrid";
|
||||
import { apiFetchListForPrerender } from "@/lib/api";
|
||||
import { Calendar, CalendarX } from "lucide-react";
|
||||
import { Calendar } from "lucide-react";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
@@ -27,6 +27,8 @@ type Event = {
|
||||
picture?: string;
|
||||
isSoldOut?: boolean;
|
||||
cashupStatus?: string;
|
||||
campusId?: string | null;
|
||||
campus?: { id: string; name: string; slug: string; colour: string | null } | null;
|
||||
};
|
||||
|
||||
export default async function EventsPage() {
|
||||
@@ -52,18 +54,7 @@ export default async function EventsPage() {
|
||||
<p className="text-sm text-gray-500">{sorted.length} upcoming event{sorted.length === 1 ? "" : "s"}</p>
|
||||
</div>
|
||||
</div>
|
||||
{sorted.length === 0 ? (
|
||||
<div className="text-center py-16 border rounded-xl bg-gray-50">
|
||||
<CalendarX className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||
<p className="text-gray-500 text-sm">No events available right now — check back soon.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-3 sm:grid-cols-2">
|
||||
{sorted.map((event) => (
|
||||
<EventCard key={event.id} event={event} />)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<EventsGrid events={sorted} />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,19 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
// same degrade-gracefully posture as layout.tsx's getServerSettings.
|
||||
}
|
||||
|
||||
// Campus pages, while campuses are on (the list is empty otherwise).
|
||||
let campuses: { slug: string }[] = [];
|
||||
try {
|
||||
campuses = (await apiFetch<{ campuses: { slug: string }[] }>("/api/campuses", { nextOptions: { next: { revalidate } } })).campuses || [];
|
||||
} catch {
|
||||
// Same as events above.
|
||||
}
|
||||
const campusEntries: MetadataRoute.Sitemap = campuses.map((c) => ({
|
||||
url: `${base}/campus/${c.slug}`,
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.6,
|
||||
}));
|
||||
|
||||
const eventEntries: MetadataRoute.Sitemap = (events || []).map((e) => ({
|
||||
url: `${base}/events/${e.id}`,
|
||||
lastModified: e.updatedAt ? new Date(e.updatedAt) : undefined,
|
||||
@@ -30,5 +43,5 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
priority: 0.7,
|
||||
}));
|
||||
|
||||
return [...staticEntries, ...eventEntries];
|
||||
return [...staticEntries, ...campusEntries, ...eventEntries];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { MapPin, Plus, ArrowUp, ArrowDown, Archive, ArchiveRestore, Trash2, Pencil } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { reloadCampuses, type Campus } from "@/hooks/useCampuses";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type Person = { id: string; name: string; email: string };
|
||||
type AdminCampus = Campus & {
|
||||
archivedAt: string | null;
|
||||
notifyRecipients: Person[];
|
||||
_count: { events: number; homeOf: number };
|
||||
};
|
||||
type Draft = {
|
||||
name: string; slug: string; address: string; defaultLocation: string;
|
||||
contactName: string; contactEmail: string; contactPhone: string; colour: string; logoUrl: string;
|
||||
notifyRecipients: Person[];
|
||||
};
|
||||
|
||||
const EMPTY: Draft = { name: "", slug: "", address: "", defaultLocation: "", contactName: "", contactEmail: "", contactPhone: "", colour: "", logoUrl: "", notifyRecipients: [] };
|
||||
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
|
||||
|
||||
const toDraft = (c: AdminCampus): Draft => ({
|
||||
name: c.name, slug: c.slug, address: c.address || "", defaultLocation: c.defaultLocation || "",
|
||||
contactName: c.contactName || "", contactEmail: c.contactEmail || "", contactPhone: c.contactPhone || "",
|
||||
colour: c.colour || "", logoUrl: c.logoUrl || "", notifyRecipients: c.notifyRecipients || [],
|
||||
});
|
||||
|
||||
/** Staff who get the daily summary for this campus's events. */
|
||||
function RecipientsField({ value, onChange }: { value: Person[]; onChange: (v: Person[]) => void }) {
|
||||
const { token } = useAuth();
|
||||
const [search, setSearch] = useState("");
|
||||
const [results, setResults] = useState<Person[]>([]);
|
||||
useEffect(() => {
|
||||
if (!search.trim()) { setResults([]); return; }
|
||||
let cancelled = false;
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const res = await apiFetch<{ data?: (Person & { role?: string })[] }>(`/api/users?search=${encodeURIComponent(search.trim())}&limit=10`, { authToken: token });
|
||||
if (!cancelled) setResults((res?.data || []).filter(u => u.role !== "user"));
|
||||
} catch { if (!cancelled) setResults([]); }
|
||||
}, 300);
|
||||
return () => { cancelled = true; clearTimeout(t); };
|
||||
}, [search, token]);
|
||||
const shown = results.filter(r => !value.some(v => v.id === r.id));
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{value.length > 0 && (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{value.map(p => (
|
||||
<li key={p.id} className="inline-flex items-center gap-1.5 text-xs bg-gray-100 rounded-full pl-3 pr-1 py-1">
|
||||
{p.name}
|
||||
<button type="button" onClick={() => onChange(value.filter(v => v.id !== p.id))} className="rounded-full px-1.5 hover:bg-gray-200" aria-label={`Remove ${p.name}`}>×</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<input className={inputCls} placeholder="Search staff by name or email…" value={search} onChange={e => setSearch(e.target.value)} />
|
||||
{shown.length > 0 && (
|
||||
<ul className="border rounded-lg divide-y max-h-48 overflow-y-auto">
|
||||
{shown.map(u => (
|
||||
<li key={u.id}>
|
||||
<button type="button" className="w-full text-left px-3 py-1.5 text-sm hover:bg-brand-50" onClick={() => { onChange([...value, u]); setSearch(""); setResults([]); }}>
|
||||
{u.name} <span className="text-xs text-gray-400">{u.email}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CampusForm({ initial, saving, onSave, onCancel }: { initial: Draft; saving: boolean; onSave: (d: Draft) => void; onCancel: () => void }) {
|
||||
const [d, setD] = useState<Draft>(initial);
|
||||
const set = (k: keyof Draft) => (e: React.ChangeEvent<HTMLInputElement>) => setD(prev => ({ ...prev, [k]: e.target.value }));
|
||||
return (
|
||||
<form onSubmit={e => { e.preventDefault(); onSave(d); }} className="border rounded-xl p-4 space-y-3 bg-gray-50">
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<label className="block text-sm"><span className="text-gray-700 font-medium">Name</span>
|
||||
<input className={inputCls} value={d.name} onChange={set("name")} required maxLength={100} placeholder="e.g. Henley Campus" /></label>
|
||||
<label className="block text-sm"><span className="text-gray-700 font-medium">Web address</span>
|
||||
<input className={inputCls} value={d.slug} onChange={set("slug")} maxLength={60} placeholder="made from the name" />
|
||||
<span className="text-xs text-gray-500">The campus page: /campus/{d.slug || "…"}</span></label>
|
||||
<label className="block text-sm sm:col-span-2"><span className="text-gray-700 font-medium">Address</span>
|
||||
<input className={inputCls} value={d.address} onChange={set("address")} maxLength={300} /></label>
|
||||
<label className="block text-sm sm:col-span-2"><span className="text-gray-700 font-medium">Default event location</span>
|
||||
<input className={inputCls} value={d.defaultLocation} onChange={set("defaultLocation")} maxLength={300} placeholder="Filled in when an event is created for this campus" /></label>
|
||||
<label className="block text-sm"><span className="text-gray-700 font-medium">Contact name</span>
|
||||
<input className={inputCls} value={d.contactName} onChange={set("contactName")} maxLength={100} /></label>
|
||||
<label className="block text-sm"><span className="text-gray-700 font-medium">Contact email</span>
|
||||
<input type="email" className={inputCls} value={d.contactEmail} onChange={set("contactEmail")} maxLength={200} /></label>
|
||||
<label className="block text-sm"><span className="text-gray-700 font-medium">Contact phone</span>
|
||||
<input className={inputCls} value={d.contactPhone} onChange={set("contactPhone")} maxLength={40} /></label>
|
||||
<label className="block text-sm"><span className="text-gray-700 font-medium">Colour</span>
|
||||
<span className="flex gap-2">
|
||||
<input type="color" className="h-9 w-12 border rounded" value={d.colour || "#2563eb"} onChange={set("colour")} aria-label="Pick a colour" />
|
||||
<input className={inputCls} value={d.colour} onChange={set("colour")} placeholder="optional, e.g. #1d4ed8" maxLength={7} />
|
||||
</span></label>
|
||||
<label className="block text-sm sm:col-span-2"><span className="text-gray-700 font-medium">Logo address</span>
|
||||
<input className={inputCls} value={d.logoUrl} onChange={set("logoUrl")} maxLength={500} placeholder="optional, https://…" /></label>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-700 font-medium">Daily summary for this campus</p>
|
||||
<p className="text-xs text-gray-500 mb-2">These staff also get the daily summary of this campus's events, on top of each event's own recipients.</p>
|
||||
<RecipientsField value={d.notifyRecipients} onChange={v => setD(prev => ({ ...prev, notifyRecipients: v }))} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button disabled={saving || !d.name.trim()} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">{saving ? "Saving…" : "Save campus"}</button>
|
||||
<button type="button" onClick={onCancel} className="px-4 py-2 rounded-lg border text-sm hover:bg-white">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** Site settings → Campuses: turn campuses on, and add, order, archive and delete them. */
|
||||
export function CampusesTab({ active }: { active: boolean }) {
|
||||
const { token } = useAuth();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [campuses, setCampuses] = useState<AdminCampus[] | null>(null);
|
||||
const [editing, setEditing] = useState<string | "new" | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await apiFetch<{ enabled: boolean; campuses: AdminCampus[] }>("/api/campuses/admin", { authToken: token });
|
||||
setEnabled(res.enabled);
|
||||
setCampuses(res.campuses);
|
||||
} catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Could not load campuses." }); }
|
||||
}, [token, setMsg]);
|
||||
useEffect(() => { if (active) load(); }, [active, load]);
|
||||
|
||||
const run = async (fn: () => Promise<unknown>, ok?: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn();
|
||||
reloadCampuses();
|
||||
await load();
|
||||
if (ok) setMsg({ type: "ok", text: ok });
|
||||
return true;
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Something went wrong." });
|
||||
return false;
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const toggle = (on: boolean) => run(() => apiFetch("/api/campuses/settings", { method: "PUT", authToken: token, body: { enabled: on } }), on ? "Campuses are on." : "Campuses are off — campus fields are hidden, nothing is deleted.");
|
||||
const save = async (d: Draft, id?: string) => {
|
||||
const body = { ...d, notifyRecipients: undefined, notifyRecipientIds: d.notifyRecipients.map(p => p.id) };
|
||||
const ok = await run(() => id
|
||||
? apiFetch(`/api/campuses/${id}`, { method: "PUT", authToken: token, body })
|
||||
: apiFetch("/api/campuses", { method: "POST", authToken: token, body }).then((c) => {
|
||||
const created = c as AdminCampus;
|
||||
// Recipients are set with an update, so a new campus gets them too.
|
||||
return d.notifyRecipients.length ? apiFetch(`/api/campuses/${created.id}`, { method: "PUT", authToken: token, body: { notifyRecipientIds: body.notifyRecipientIds } }) : created;
|
||||
}), id ? "Campus saved." : "Campus added.");
|
||||
if (ok) setEditing(null);
|
||||
};
|
||||
const move = (list: AdminCampus[], i: number, dir: -1 | 1) => {
|
||||
const ids = list.map(c => c.id);
|
||||
[ids[i], ids[i + dir]] = [ids[i + dir], ids[i]];
|
||||
return run(() => apiFetch("/api/campuses/order", { method: "PUT", authToken: token, body: { ids } }));
|
||||
};
|
||||
const setArchived = (c: AdminCampus, archived: boolean) => run(() => apiFetch(`/api/campuses/${c.id}`, { method: "PUT", authToken: token, body: { archived } }), archived ? `${c.name} archived.` : `${c.name} restored.`);
|
||||
const remove = (c: AdminCampus) => {
|
||||
if (!window.confirm(`Delete ${c.name}? This can't be undone.`)) return;
|
||||
run(() => apiFetch(`/api/campuses/${c.id}`, { method: "DELETE", authToken: token }), `${c.name} deleted.`);
|
||||
};
|
||||
|
||||
if (!campuses) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
|
||||
const activeList = campuses.filter(c => !c.archivedAt);
|
||||
const archivedList = campuses.filter(c => c.archivedAt);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Campuses</h2>
|
||||
<p className="text-sm text-gray-500">For a church with more than one campus: each event belongs to a campus or to all campuses, the events page gets a campus filter, and people can choose their home campus. Churches with one campus can leave this off — nobody sees campus fields then.</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-3 border rounded-xl p-4 cursor-pointer">
|
||||
<input type="checkbox" className="mt-1 h-4 w-4" checked={enabled} disabled={busy} onChange={e => toggle(e.target.checked)} />
|
||||
<span>
|
||||
<span className="block font-medium text-gray-900">Use campuses</span>
|
||||
<span className="block text-sm text-gray-500">Turning this off hides campus fields and filters everywhere. Campuses and the campus on each event are kept, and come back when it's turned on again.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{activeList.length === 0 && editing !== "new" && <p className="text-sm text-gray-500">No campuses yet. Add your first one below.</p>}
|
||||
{activeList.map((c, i) => editing === c.id ? (
|
||||
<CampusForm key={c.id} initial={toDraft(c)} saving={busy} onSave={d => save(d, c.id)} onCancel={() => setEditing(null)} />
|
||||
) : (
|
||||
<div key={c.id} className="border rounded-xl p-3 flex flex-wrap items-center gap-3">
|
||||
<span className="w-3 h-3 rounded-full shrink-0 border" style={{ background: c.colour || "transparent" }} aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-gray-900">{c.name}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
<Link href={`/campus/${c.slug}`} className="hover:underline">/campus/{c.slug}</Link>
|
||||
{" · "}{c._count.events} event{c._count.events === 1 ? "" : "s"}{" · "}{c._count.homeOf} {c._count.homeOf === 1 ? "person" : "people"} call it home
|
||||
{c.address && <> · <MapPin className="inline w-3 h-3" /> {c.address}</>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => move(activeList, i, -1)} disabled={busy || i === 0} className="p-1.5 rounded hover:bg-gray-100 disabled:opacity-30" aria-label="Move up"><ArrowUp className="w-4 h-4" /></button>
|
||||
<button onClick={() => move(activeList, i, 1)} disabled={busy || i === activeList.length - 1} className="p-1.5 rounded hover:bg-gray-100 disabled:opacity-30" aria-label="Move down"><ArrowDown className="w-4 h-4" /></button>
|
||||
<button onClick={() => setEditing(c.id)} disabled={busy} className="p-1.5 rounded hover:bg-gray-100" aria-label={`Edit ${c.name}`}><Pencil className="w-4 h-4" /></button>
|
||||
<button onClick={() => setArchived(c, true)} disabled={busy} className="p-1.5 rounded hover:bg-gray-100" aria-label={`Archive ${c.name}`} title="Archive: hidden from filters and new events; its events keep it"><Archive className="w-4 h-4" /></button>
|
||||
{c._count.events === 0 && (
|
||||
<button onClick={() => remove(c)} disabled={busy} className="p-1.5 rounded hover:bg-red-50 text-red-600" aria-label={`Delete ${c.name}`}><Trash2 className="w-4 h-4" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{editing === "new" ? (
|
||||
<CampusForm initial={EMPTY} saving={busy} onSave={d => save(d)} onCancel={() => setEditing(null)} />
|
||||
) : (
|
||||
<button onClick={() => setEditing("new")} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border text-sm hover:bg-gray-50">
|
||||
<Plus className="w-4 h-4" /> Add a campus
|
||||
</button>
|
||||
)}
|
||||
|
||||
{archivedList.length > 0 && (
|
||||
<div className="pt-2">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-2">Archived</h3>
|
||||
<p className="text-xs text-gray-500 mb-2">Archived campuses aren't offered for new events or as a home campus. Events that already have one keep it.</p>
|
||||
<div className="space-y-2">
|
||||
{archivedList.map(c => (
|
||||
<div key={c.id} className="border rounded-xl p-3 flex items-center gap-3 bg-gray-50">
|
||||
<p className="flex-1 text-sm text-gray-700">{c.name} <span className="text-xs text-gray-500">· {c._count.events} event{c._count.events === 1 ? "" : "s"}</span></p>
|
||||
<button onClick={() => setArchived(c, false)} disabled={busy} className="inline-flex items-center gap-1 text-sm px-2 py-1 rounded hover:bg-white"><ArchiveRestore className="w-4 h-4" /> Restore</button>
|
||||
{c._count.events === 0 && (
|
||||
<button onClick={() => remove(c)} disabled={busy} className="p-1.5 rounded hover:bg-red-50 text-red-600" aria-label={`Delete ${c.name}`}><Trash2 className="w-4 h-4" /></button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{msg && <div className={`p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { Calendar, MapPin } from "lucide-react";
|
||||
import { ContactButton } from "@/components/events/ContactButton";
|
||||
import { CampusBadge } from "@/components/shared/CampusFilter";
|
||||
|
||||
type Event = {
|
||||
id: string;
|
||||
@@ -24,6 +25,7 @@ import { ContactButton } from "@/components/events/ContactButton";
|
||||
scheduleType?: "single" | "sessions";
|
||||
sessions?: EventSession[];
|
||||
registrationClosesAt?: string;
|
||||
campus?: { id: string; name: string; slug: string; colour: string | null } | null;
|
||||
};
|
||||
|
||||
import { formatDateTimeRange } from "@/lib/date";
|
||||
@@ -66,6 +68,7 @@ export const EventCard = ({ event }: { event: Event }) => {
|
||||
)}
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-semibold hover:underline">{event.title}</h3>
|
||||
<CampusBadge campus={event.campus} className="mt-1" />
|
||||
<p className="text-sm text-gray-500 flex items-center gap-1.5 mt-1">
|
||||
<Calendar className="w-3.5 h-3.5 shrink-0" />
|
||||
{dateRange}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { CalendarX } from "lucide-react";
|
||||
import { EventCard } from "@/components/events/EventCard";
|
||||
import { CampusFilter } from "@/components/shared/CampusFilter";
|
||||
import { useCampuses } from "@/hooks/useCampuses";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
type GridEvent = React.ComponentProps<typeof EventCard>["event"] & { campusId?: string | null };
|
||||
|
||||
// The visitor's campus choice, kept in this browser (like the cookie choice).
|
||||
const STORAGE_KEY = "hope_events_campus";
|
||||
|
||||
function readStored(): string | null {
|
||||
try { return localStorage.getItem(STORAGE_KEY); } catch { return null; }
|
||||
}
|
||||
function store(value: string) {
|
||||
try { localStorage.setItem(STORAGE_KEY, value); } catch { /* private window etc. */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* The public events grid with a campus filter (while campuses are on). A campus shows its own
|
||||
* events plus church-wide ones. Starts on the visitor's last choice, else their home campus.
|
||||
*/
|
||||
export function EventsGrid({ events }: { events: GridEvent[] }) {
|
||||
const { enabled, campuses } = useCampuses();
|
||||
const { user } = useAuth();
|
||||
const [campus, setCampus] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const stored = readStored();
|
||||
const start = stored ?? user?.homeCampusId ?? "";
|
||||
// Only a campus that still exists (an archived one falls back to every campus).
|
||||
setCampus(start && campuses.some(c => c.id === start) ? start : "");
|
||||
}, [enabled, campuses, user?.homeCampusId]);
|
||||
|
||||
const choose = (value: string) => { setCampus(value); store(value); };
|
||||
const shown = enabled && campus ? events.filter(e => !e.campusId || e.campusId === campus) : events;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<CampusFilter value={campus} onChange={choose} id="events-campus" />
|
||||
</div>
|
||||
{shown.length === 0 ? (
|
||||
<div className="text-center py-16 border rounded-xl bg-gray-50">
|
||||
<CalendarX className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||
<p className="text-gray-500 text-sm">{events.length ? "No events at this campus right now — try another campus." : "No events available right now — check back soon."}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-3 sm:grid-cols-2">
|
||||
{shown.map(event => <EventCard key={event.id} event={event} />)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { HorizontalBarChart } from "./charts/HorizontalBarChart";
|
||||
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { registrationOptionUnitPrice } from "@/lib/pricing";
|
||||
import { CampusFilter, matchesCampus } from "@/components/shared/CampusFilter";
|
||||
import type { ConsentRow, Event, EventCashup, EventFinancials, MedicalRow, Payment, Registration, Ticket, UserSummary } from "@/types";
|
||||
|
||||
const METHOD_KEYS = ["cash", "card", "eft", "other"] as const;
|
||||
@@ -164,6 +165,8 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
// hiding them by default would work against those reports' whole purpose. This toggle
|
||||
// lets staff exclude them when they only want currently-open events.
|
||||
const [showClosedEvents, setShowClosedEvents] = useState(true);
|
||||
// Multi-campus: one campus's events, church-wide ones, or all (the church-wide total).
|
||||
const [campus, setCampus] = useState("");
|
||||
const isAdmin = role === "admin";
|
||||
|
||||
const filteredEvents = useMemo(() => {
|
||||
@@ -172,9 +175,10 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
if (!showPastEvents && ev.endDate && new Date(ev.endDate) < now) return false;
|
||||
if (!showInactiveEvents && ev.isActive === false) return false;
|
||||
if (!showClosedEvents && ev.cashupStatus === "closed") return false;
|
||||
if (!matchesCampus(ev, campus)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [events, showPastEvents, showInactiveEvents, showClosedEvents]);
|
||||
}, [events, showPastEvents, showInactiveEvents, showClosedEvents, campus]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -1457,6 +1461,15 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
)}
|
||||
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
|
||||
|
||||
<CampusFilter
|
||||
value={campus}
|
||||
onChange={v => { setCampus(v); setSelectedEventIds([]); }}
|
||||
withChurchWide
|
||||
everyLabel="All campuses (church-wide total)"
|
||||
className="mb-3"
|
||||
id="reports-campus"
|
||||
/>
|
||||
|
||||
<ReportsShell
|
||||
events={filteredEvents}
|
||||
isAdmin={isAdmin}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Users } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useCampuses } from "@/hooks/useCampuses";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* "Everyone at campus X" for the email/WhatsApp broadcast composers: adds the people whose
|
||||
* home campus is X — and, ticked, people registered for X's events in the last 12 months — to
|
||||
* the selected users. News consent is still checked when the message is sent. Renders
|
||||
* nothing while campuses are off.
|
||||
*/
|
||||
export function CampusAudiencePicker({ selectedIds, onChange }: { selectedIds: string[]; onChange: (ids: string[]) => void }) {
|
||||
const { token } = useAuth();
|
||||
const { enabled, campuses } = useCampuses();
|
||||
const [campusId, setCampusId] = useState("");
|
||||
const [registrants, setRegistrants] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
if (!enabled || campuses.length === 0) return null;
|
||||
|
||||
const add = async () => {
|
||||
if (!campusId) return;
|
||||
setBusy(true);
|
||||
setNote(null);
|
||||
try {
|
||||
const res = await apiFetch<{ campus: { name: string }; users: { id: string }[] }>(`/api/campuses/${campusId}/audience${registrants ? "?registrants=true" : ""}`, { authToken: token });
|
||||
const ids = res.users.map(u => u.id);
|
||||
const merged = Array.from(new Set([...selectedIds, ...ids]));
|
||||
onChange(merged);
|
||||
const added = merged.length - selectedIds.length;
|
||||
const people = (n: number) => `${n} ${n === 1 ? "person" : "people"}`;
|
||||
setNote({
|
||||
ok: true,
|
||||
text: ids.length === 0 ? `Nobody at ${res.campus.name} yet.`
|
||||
: added === ids.length ? `Added ${people(added)} at ${res.campus.name}.`
|
||||
: `Added ${people(added)} at ${res.campus.name} — ${ids.length - added} already selected.`,
|
||||
});
|
||||
} catch (e) {
|
||||
setNote({ ok: false, text: errorMessage(e) || "Could not load the campus's people." });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-3 bg-gray-50 space-y-2">
|
||||
<p className="text-xs font-medium text-gray-700 flex items-center gap-1.5"><Users className="w-3.5 h-3.5" /> Everyone at a campus</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select className="border rounded px-2 py-1.5 text-sm bg-white" value={campusId} onChange={e => setCampusId(e.target.value)} aria-label="Campus">
|
||||
<option value="">Choose a campus…</option>
|
||||
{campuses.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<button type="button" onClick={add} disabled={!campusId || busy} className="px-3 py-1.5 rounded border text-sm bg-white hover:bg-gray-100 disabled:opacity-50">{busy ? "Adding…" : "Add them"}</button>
|
||||
</div>
|
||||
<label className="flex items-start gap-2 text-xs text-gray-600">
|
||||
<input type="checkbox" className="mt-0.5" checked={registrants} onChange={e => setRegistrants(e.target.checked)} />
|
||||
Also people who registered for this campus's events in the last 12 months (not only those who chose it as their home campus)
|
||||
</label>
|
||||
{note && <p className={`text-xs ${note.ok ? "text-green-700" : "text-red-700"}`}>{note.text}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { useCampuses, type EventCampus } from "@/hooks/useCampuses";
|
||||
|
||||
/**
|
||||
* A campus dropdown for lists and reports. Renders nothing while campuses are off.
|
||||
* Values: "" = every campus (church-wide total), "all" = only events for all campuses
|
||||
* (when `withChurchWide`), otherwise a campus id.
|
||||
*/
|
||||
export function CampusFilter({ value, onChange, withChurchWide = false, everyLabel = "All campuses", className = "", id = "campus-filter" }: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
withChurchWide?: boolean;
|
||||
everyLabel?: string;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}) {
|
||||
const { enabled, campuses } = useCampuses();
|
||||
if (!enabled || campuses.length === 0) return null;
|
||||
return (
|
||||
<label className={`inline-flex items-center gap-2 text-sm ${className}`} htmlFor={id}>
|
||||
<span className="sr-only">Campus</span>
|
||||
<MapPin className="w-4 h-4 text-gray-400" aria-hidden />
|
||||
<select id={id} className="border rounded px-2 py-1.5 text-sm bg-white" value={value} onChange={e => onChange(e.target.value)}>
|
||||
<option value="">{everyLabel}</option>
|
||||
{campuses.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
{withChurchWide && <option value="all">Church-wide events only</option>}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** Does an event pass a CampusFilter value? */
|
||||
export function matchesCampus(ev: { campusId?: string | null }, value: string): boolean {
|
||||
if (!value) return true;
|
||||
if (value === "all") return !ev.campusId;
|
||||
return ev.campusId === value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The campus an event belongs to, as a small label (`link`: to the campus page). Nothing for
|
||||
* church-wide events or while campuses are off.
|
||||
*/
|
||||
export function CampusBadge({ campus, className = "", link = false }: { campus?: EventCampus | null; className?: string; link?: boolean }) {
|
||||
const { enabled } = useCampuses();
|
||||
if (!enabled || !campus) return null;
|
||||
const cls = `inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-700 ${link ? "hover:bg-gray-200" : ""} ${className}`;
|
||||
const inner = (
|
||||
<>
|
||||
<span className="w-2 h-2 rounded-full border" style={{ background: campus.colour || "transparent" }} aria-hidden />
|
||||
{campus.name}
|
||||
</>
|
||||
);
|
||||
return link ? <Link href={`/campus/${campus.slug}`} className={cls}>{inner}</Link> : <span className={cls}>{inner}</span>;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play, CreditCard, ArrowUpDown, KeyRound, ArrowUpCircle } from "lucide-react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play, CreditCard, ArrowUpDown, KeyRound, ArrowUpCircle, MapPinned, Archive, Users } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -32,6 +32,30 @@ export const siteSettingsHelpContent: HelpContent = {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "campuses",
|
||||
label: "Campuses",
|
||||
icon: MapPinned,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p>For a church with more than one campus. Off by default — a church with one campus never sees campus fields anywhere.</p>
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={MapPinned} title="Turning campuses on" tone="brand">
|
||||
Tick <strong>Use campuses</strong>, then add each campus: its name, web address (its page is <code>/campus/…</code>), address, contact, colour and the default location for its events. The order you set is the order of every campus list.
|
||||
</GuideItem>
|
||||
<GuideItem icon={MapPinned} title="Events and the public site" tone="brand">
|
||||
Each event belongs to one campus, or to <strong>All campuses</strong> for church-wide conferences and camps. The events page gets a campus filter (remembered per visitor, starting on their home campus); a campus shows its own events plus the church-wide ones.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Users} title="People and messages" tone="gray">
|
||||
People can choose a home campus on their profile. In Email and WhatsApp broadcasts, <strong>Everyone at a campus</strong> adds the people whose home campus it is — and, ticked, people who registered for its events in the last 12 months. News still only goes to people who opted in.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Archive} title="Archiving, deleting, turning off" tone="amber">
|
||||
A campus with events can't be deleted — archive it instead: it disappears from filters and new events, and its events keep it. Turning campuses off hides everything campus-related; nothing is deleted, and it all comes back when you turn them on again.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "branding",
|
||||
label: "Branding",
|
||||
|
||||
@@ -32,7 +32,7 @@ export const supervisorEventsHelpContent: HelpContent = {
|
||||
<p>Creating or editing an event walks through several steps:</p>
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Calendar} title="Basic details" tone="brand">
|
||||
Title, description, start/end times, registration deadline, go-live time, base price, picture, and a custom URL alias.
|
||||
Title, description, start/end times, registration deadline, go-live time, base price, picture, and a custom URL alias. With campuses on (Site settings → Campuses), also the <strong>campus</strong> — one campus, or All campuses for a church-wide event. Choosing a campus fills in its default location.
|
||||
</GuideItem>
|
||||
<GuideItem icon={CalendarDays} title="Multiple sessions" tone="brand">
|
||||
Choose "Multiple sessions" for one event on several dates — e.g. every Wednesday 18:30–20:30 for 5 weeks. Use the repeat pattern to create the dates, then edit any of them, add a label ("Week 1 — …"), a different location, or an extra date. People register and pay once and get one ticket for every session. Cancel a session (e.g. a public holiday) rather than deleting it once people have been checked in. Registration closes when the first session starts unless you turn on late joining.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export type Campus = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
address: string | null;
|
||||
colour: string | null;
|
||||
logoUrl: string | null;
|
||||
defaultLocation: string | null;
|
||||
contactName: string | null;
|
||||
contactEmail: string | null;
|
||||
contactPhone: string | null;
|
||||
order: number;
|
||||
};
|
||||
|
||||
/** What an event carries about its campus. */
|
||||
export type EventCampus = { id: string; name: string; slug: string; colour: string | null };
|
||||
|
||||
type CampusState = { enabled: boolean; campuses: Campus[] };
|
||||
|
||||
// One request per page load, shared by every component that asks.
|
||||
let cache: Promise<CampusState> | null = null;
|
||||
|
||||
function load(): Promise<CampusState> {
|
||||
if (!cache) {
|
||||
cache = apiFetch<CampusState>("/api/campuses").catch(() => {
|
||||
cache = null; // try again next time
|
||||
return { enabled: false, campuses: [] };
|
||||
});
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
/** Forget the cached list (after Site settings → Campuses changes it). */
|
||||
export function reloadCampuses() {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether campuses are on (Site settings → Campuses) and the active campuses. While off,
|
||||
* `enabled` is false and `campuses` is empty, so callers hide every campus field.
|
||||
*/
|
||||
export function useCampuses() {
|
||||
const [state, setState] = useState<CampusState & { loading: boolean }>({ enabled: false, campuses: [], loading: true });
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
load().then(s => { if (alive) setState({ ...s, loading: false }); });
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
return state;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export type AuthUser = {
|
||||
newsPromptPending?: boolean;
|
||||
// POPIA: a privacy notice version they must accept has been published since they last did.
|
||||
noticePromptPending?: boolean;
|
||||
// Multi-campus: their home campus (the events page starts on it).
|
||||
homeCampusId?: string | null;
|
||||
};
|
||||
|
||||
export type AuthResponse = AuthUser & {
|
||||
|
||||
@@ -31,6 +31,8 @@ export type User = {
|
||||
isActive?: boolean;
|
||||
notificationPreference?: NotificationPreference;
|
||||
twoFactorEnabled?: boolean;
|
||||
// Multi-campus: their home campus (only used while campuses are on).
|
||||
homeCampusId?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
@@ -113,6 +115,9 @@ export type Event = {
|
||||
price: number;
|
||||
picture?: string | null;
|
||||
location?: string | null;
|
||||
// Multi-campus: null = all campuses. `campus` is included by the event endpoints.
|
||||
campusId?: string | null;
|
||||
campus?: { id: string; name: string; slug: string; colour: string | null } | null;
|
||||
isActive?: boolean;
|
||||
isHidden?: boolean;
|
||||
isLive?: boolean;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.42.1",
|
||||
"version": "1.43.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.42.1",
|
||||
"version": "1.43.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.42.1",
|
||||
"version": "1.43.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user