Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36c3b117ca | ||
|
|
cad075bb3f | ||
|
|
159728d269 | ||
|
|
c570fc7ffb | ||
|
|
00fd26f3a5 | ||
|
|
344f260011 | ||
|
|
d2d9282a2c | ||
|
|
f36e84fd90 | ||
|
|
a6909ce506 | ||
|
|
955f8d9831 | ||
|
|
2727fc6554 | ||
|
|
3d149e6098 | ||
|
|
30a2142085 | ||
|
|
95913a5bd5 | ||
|
|
f7e0ec8bc1 | ||
|
|
08d442e1c1 | ||
|
|
be4f341e42 |
@@ -7,6 +7,69 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.44.0] - 2026-10-27
|
||||
|
||||
### Added
|
||||
|
||||
- **Staff for some campuses** (Admin → Users → "Limit to campuses", while campuses are on). A
|
||||
supervisor or staff member limited to some campuses sees only those campuses' events plus
|
||||
events for all campuses — in every list, report, dashboard, the scanner and offline scanning.
|
||||
- **Events for all campuses:** they can scan, register people and take payments at the door, but
|
||||
not change them, cash them up or refund them (the event editor opens read-only).
|
||||
- **New events** go at one of their campuses; they can't move events to another campus.
|
||||
- **Admins and supervisors for every campus only:** the site banner, payment reconciliation and
|
||||
open online checkouts, and donations not linked to an event.
|
||||
- **Messages:** broadcasts only to people at their campuses or attendees of their events (no
|
||||
typed-in addresses). **Coupons:** only for their campuses' events.
|
||||
- Another campus's records answer "not found"; the **scanner says which campus** a ticket is
|
||||
for. Staff still see their own registrations for any campus's event.
|
||||
- Their dashboard says which campuses they work with. Admins are never limited, and limits are
|
||||
ignored while campuses are off (the Campuses tab warns first). Changing someone's limits is in
|
||||
the audit log; a campus with staff limited to it can't be deleted.
|
||||
- Every staff and supervisor route declares how it handles campus limits, and a test fails on
|
||||
any that doesn't (`tests/campusScopeRoutes.test.js`).
|
||||
|
||||
## [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
|
||||
|
||||
- **The production Hub's public key is built in** (`backend/src/hub/publicKey.js`), so sites can
|
||||
check licences without `HUB_PUBLIC_KEY` in their `.env`. `HUB_PUBLIC_KEY` still overrides it (for
|
||||
a local Hub while developing).
|
||||
|
||||
## [1.42.0] - 2026-10-27
|
||||
|
||||
### Changed
|
||||
|
||||
+38
-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,35 @@ 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.
|
||||
- **Staff for some campuses** (Admin → Users → "Limit to campuses"): a supervisor or staff member
|
||||
sees only their campuses' events plus events for all campuses — every list, report, dashboard
|
||||
and scanner follows it. For church-wide events they can scan, register people and take payments
|
||||
at the door, but not change, cash up or refund them. The site banner, payment reconciliation and
|
||||
donations not linked to an event are for admins and unscoped supervisors; broadcasts only reach
|
||||
people at their campuses or their events' attendees; coupons only for their campuses' events.
|
||||
Another campus's records answer "not found"; the scanner says which campus a ticket is for.
|
||||
Admins are never limited; limits are ignored while campuses are off.
|
||||
|
||||
---
|
||||
|
||||
## Accounts & security
|
||||
|
||||
- Register, log in, forgot/reset password, activate account (for accounts
|
||||
@@ -689,6 +720,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,44 @@ 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-scoped staff (1.44.0):** a supervisor or staff member with campus limits
|
||||
(`PUT /api/users/:id { scopedCampusIds }`, admin; `GET /api/users/:id` returns `scopedCampuses`;
|
||||
`GET /api/users/profile` returns `campusScope`, null when unscoped or campuses are off) only sees
|
||||
their campuses' events plus church-wide ones on every staff/supervisor endpoint
|
||||
(`src/utils/campusScope.js`). Responses for out-of-scope records:
|
||||
- `404 { message: "Not found" }` — another campus's event or record (nothing leaks).
|
||||
- `403 { code: "OTHER_CAMPUS" }` — the scanner (`/api/tickets/scan-preview|qr|scan/:qrCode`), naming the ticket's campus.
|
||||
- `403 { code: "CHURCH_WIDE_EVENT" }` — changing, cashing up or refunding an event for all campuses.
|
||||
- `403 { code: "CAMPUS_SCOPED" }` — banner, payment reconciliation, open checkouts, unlinked donations, typed-in broadcast addresses or recipients outside their campuses.
|
||||
- `409 { code: "CAMPUS_HAS_SCOPED_STAFF" }` on `DELETE /api/campuses/:id` while staff are limited to it.
|
||||
|
||||
**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.0",
|
||||
"version": "1.44.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.42.0",
|
||||
"version": "1.44.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.42.0",
|
||||
"version": "1.44.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;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'user_campus_scope_changed';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_UserCampusScope" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "_UserCampusScope_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_UserCampusScope_B_index" ON "_UserCampusScope"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_UserCampusScope" ADD CONSTRAINT "_UserCampusScope_A_fkey" FOREIGN KEY ("A") REFERENCES "Campus"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_UserCampusScope" ADD CONSTRAINT "_UserCampusScope_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -141,6 +141,46 @@ 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")
|
||||
// Campus-scoped staff (1.44.0): a supervisor/staff member limited to these campuses (their
|
||||
// events plus events for all campuses). Empty = unscoped. Ignored for admins and while
|
||||
// campuses are off (utils/campusScope.js).
|
||||
scopedCampuses Campus[] @relation("UserCampusScope")
|
||||
|
||||
@@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")
|
||||
scopedStaff User[] @relation("UserCampusScope")
|
||||
|
||||
@@index([order])
|
||||
}
|
||||
|
||||
model Event {
|
||||
@@ -187,6 +227,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 +289,7 @@ model Event {
|
||||
@@index([createdById])
|
||||
@@index([closedById])
|
||||
@@index([reopenedById])
|
||||
@@index([campusId])
|
||||
}
|
||||
|
||||
model EventOption {
|
||||
@@ -603,6 +647,14 @@ enum AdminAuditAction {
|
||||
breach_recorded
|
||||
breach_updated
|
||||
hub_connected
|
||||
hub_update_requested
|
||||
campuses_toggled
|
||||
campus_created
|
||||
campus_updated
|
||||
campus_archived
|
||||
campus_deleted
|
||||
event_campus_changed
|
||||
user_campus_scope_changed
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
|
||||
@@ -21,9 +21,13 @@ const trackStage = async (req, res) => {
|
||||
// @access Private/Supervisor
|
||||
const getFunnel = async (req, res) => {
|
||||
try {
|
||||
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
|
||||
if (eventIds.length === 0) { res.status(400); throw new Error('Choose at least one event'); }
|
||||
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { id: true, title: true } });
|
||||
const asked = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
|
||||
if (asked.length === 0) { res.status(400); throw new Error('Choose at least one event'); }
|
||||
// Campus-scoped supervisors only get the events they can see.
|
||||
const scopeWhere = await require('../utils/campusScope').listWhere(req);
|
||||
const events = await prisma.event.findMany({ where: { id: { in: asked }, ...scopeWhere }, select: { id: true, title: true } });
|
||||
const eventIds = events.map(e => e.id);
|
||||
if (eventIds.length === 0) { res.status(404); throw new Error('Event not found'); }
|
||||
const total = await funnelFor(eventIds, { from: req.query.from, to: req.query.to });
|
||||
// Per event too, so events can be compared.
|
||||
const perEvent = [];
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
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, scopedStaff: 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, scopedStaff: true } } } });
|
||||
if (!campus) return res.status(404).json({ message: 'Campus not found' });
|
||||
// Deleting would drop their limit and leave them unscoped (seeing every campus).
|
||||
if (campus._count.scopedStaff > 0) {
|
||||
return res.status(409).json({ code: 'CAMPUS_HAS_SCOPED_STAFF', message: `${campus._count.scopedStaff} staff member${campus._count.scopedStaff === 1 ? ' is' : 's are'} limited to ${campus.name}. Change their campuses in Admin → Users first, or archive the campus.` });
|
||||
}
|
||||
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 scope = await require('../utils/campusScope').scopeFor(req);
|
||||
if (scope && !scope.campusIds.includes(campus.id)) 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 };
|
||||
@@ -291,9 +291,12 @@ const resolvePending = async (req, res) => {
|
||||
const getCashupAudit = async (req, res) => {
|
||||
try {
|
||||
const { eventId, from, to } = req.query;
|
||||
// Campus-scoped supervisors only see cashups they could do: their campuses' events.
|
||||
const scope = await require('../utils/campusScope').scopeFor(req);
|
||||
|
||||
const where = {};
|
||||
if (eventId) where.eventId = eventId;
|
||||
if (scope) where.event = require('../utils/campusScope').manageWhere(scope);
|
||||
if (from || to) {
|
||||
where.createdAt = {};
|
||||
if (from) where.createdAt.gte = new Date(from);
|
||||
|
||||
@@ -5,18 +5,29 @@ const { getClientIp } = require('../utils/requestUtils');
|
||||
const { normalizeCode, previewCoupon, buildCouponLines, describeCoupon, countCouponUses } = require('../utils/coupons');
|
||||
const { resolveOptionPrice, resolveVariantTierPrice } = require('../utils/pricing');
|
||||
const { describeBundle } = require('../utils/bundles');
|
||||
const { scopeFor, couponAllowed, listWhere } = require('../utils/campusScope');
|
||||
|
||||
const TYPES = ['percent', 'fixed', 'comp'];
|
||||
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
|
||||
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
|
||||
|
||||
const COUPON_DETAIL_INCLUDE = {
|
||||
events: { include: { event: { select: { id: true, title: true, startDate: true } } } },
|
||||
events: { include: { event: { select: { id: true, title: true, startDate: true, campusId: true } } } },
|
||||
options: { include: { eventOption: { select: { id: true, name: true, eventId: true } }, variant: { select: { id: true, name: true } } } },
|
||||
allowedUsers: { include: { user: { select: { id: true, name: true, email: true } } } },
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
};
|
||||
|
||||
// Campus-scoped supervisors: only coupons limited to events at their campuses (couponAllowed).
|
||||
const couponCampuses = coupon => (coupon.events || []).map(ce => ce.event?.campusId ?? null);
|
||||
async function eventIdsAllowed(scope, eventIds) {
|
||||
if (!scope) return true;
|
||||
if (!eventIds || eventIds.length === 0) return false; // a coupon for every event
|
||||
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { campusId: true } });
|
||||
return events.length === eventIds.length && couponAllowed(scope, events.map(e => e.campusId));
|
||||
}
|
||||
const SCOPED_COUPON_MSG = 'Supervisors for some campuses can only make coupons limited to events at their campuses.';
|
||||
|
||||
const optionalDate = (v, label) => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
@@ -119,7 +130,9 @@ function relationWrites({ eventIds, scopes, userIds }, isCreate) {
|
||||
// @access Private/Supervisor
|
||||
const listCoupons = async (req, res) => {
|
||||
try {
|
||||
const coupons = await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } });
|
||||
const scope = await scopeFor(req);
|
||||
const coupons = (await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } }))
|
||||
.filter(c => couponAllowed(scope, couponCampuses(c)));
|
||||
const totals = await prisma.couponRedemption.groupBy({
|
||||
by: ['couponId'],
|
||||
where: { releasedAt: null, registration: { status: { not: 'cancelled' } } },
|
||||
@@ -157,7 +170,7 @@ const getCoupon = async (req, res) => {
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!coupon) { res.status(404); throw new Error('Coupon not found'); }
|
||||
if (!coupon || !couponAllowed(await scopeFor(req), couponCampuses(coupon))) { res.status(404); throw new Error('Coupon not found'); }
|
||||
res.json({ ...coupon, summary: describeCoupon(coupon) });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
@@ -173,6 +186,7 @@ const createCoupon = async (req, res) => {
|
||||
try {
|
||||
parsed = await parseCouponBody(req.body || {}, { isCreate: true });
|
||||
} catch (e) { res.status(400); throw e; }
|
||||
if (!(await eventIdsAllowed(await scopeFor(req), parsed.eventIds))) { res.status(403); throw new Error(SCOPED_COUPON_MSG); }
|
||||
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
|
||||
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
|
||||
}
|
||||
@@ -198,12 +212,14 @@ const createCoupon = async (req, res) => {
|
||||
// @access Private/Supervisor
|
||||
const updateCoupon = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.coupon.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) { res.status(404); throw new Error('Coupon not found'); }
|
||||
const existing = await prisma.coupon.findUnique({ where: { id: req.params.id }, include: { events: { include: { event: { select: { campusId: true } } } } } });
|
||||
const scope = await scopeFor(req);
|
||||
if (!existing || !couponAllowed(scope, couponCampuses(existing))) { res.status(404); throw new Error('Coupon not found'); }
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseCouponBody({ type: existing.type, ...req.body }, { isCreate: false });
|
||||
} catch (e) { res.status(400); throw e; }
|
||||
if (parsed.eventIds !== undefined && !(await eventIdsAllowed(scope, parsed.eventIds))) { res.status(403); throw new Error(SCOPED_COUPON_MSG); }
|
||||
if (parsed.data.code && parsed.data.code !== existing.code) {
|
||||
const used = await prisma.couponRedemption.count({ where: { couponId: existing.id } });
|
||||
if (used > 0) { res.status(400); throw new Error('This code has been used, so it can\'t be renamed. Turn it off and create a new one instead.'); }
|
||||
@@ -327,9 +343,10 @@ const listRedemptions = async (req, res) => {
|
||||
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const from = req.query.from ? new Date(req.query.from) : null;
|
||||
const to = req.query.to ? new Date(req.query.to) : null;
|
||||
const scopeEvent = await listWhere(req, 'event'); // { event: … } or {}
|
||||
const where = {
|
||||
releasedAt: null,
|
||||
registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}) },
|
||||
registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...scopeEvent },
|
||||
...(from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {}),
|
||||
};
|
||||
const rows = await prisma.couponRedemption.findMany({
|
||||
@@ -346,7 +363,7 @@ const listRedemptions = async (req, res) => {
|
||||
// discounts are recalculated, so dates go by when the registration was made.
|
||||
const regDate = from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {};
|
||||
const bundleRows = await prisma.registrationBundle.findMany({
|
||||
where: { registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...regDate } },
|
||||
where: { registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...regDate, ...scopeEvent } },
|
||||
include: {
|
||||
bundle: { include: { items: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } } } },
|
||||
registration: { select: { id: true, status: true, createdAt: true, user: { select: { id: true, name: true, email: true } }, event: { select: { id: true, title: true } } } },
|
||||
|
||||
@@ -8,6 +8,20 @@ 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 { scopeFor, canView, canManage, eventWhere: scopeEventWhere, withScope } = require('../utils/campusScope');
|
||||
|
||||
/** A campus-scoped supervisor may only create or move events to one of their campuses. */
|
||||
async function assertCampusAllowed(req, campusId) {
|
||||
const scope = await scopeFor(req);
|
||||
if (scope && !canManage(scope, campusId)) {
|
||||
const err = new Error(campusId == null
|
||||
? 'Choose one of your campuses — events for all campuses are set up by admins and supervisors for every campus.'
|
||||
: 'You can only put events at your own campuses.');
|
||||
err.statusCode = 403;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' }, include: { _count: { select: { usages: true } } } };
|
||||
|
||||
@@ -125,6 +139,11 @@ 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; }
|
||||
// Not choosing a campus means all campuses, which a scoped supervisor can't create.
|
||||
await assertCampusAllowed(req, campusPick.campusId === undefined ? null : campusPick.campusId);
|
||||
|
||||
const data = {
|
||||
id: uuidv4(),
|
||||
title,
|
||||
@@ -160,6 +179,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 +345,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 +407,7 @@ const getAllEvents = async (req, res) => {
|
||||
include: {
|
||||
eventOptions: canIncludeTiers ? { include: { earlyBirdTiers: true } } : true,
|
||||
sessions: SESSIONS_INCLUDE,
|
||||
campus: { select: EVENT_CAMPUS_SELECT },
|
||||
}
|
||||
});
|
||||
|
||||
@@ -413,6 +438,10 @@ 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 scoped = withScope(where, scopeEventWhere(await scopeFor(req)));
|
||||
|
||||
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
||||
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
|
||||
@@ -420,11 +449,12 @@ const getEventsAll = async (req, res) => {
|
||||
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) }
|
||||
: undefined;
|
||||
const events = await prisma.event.findMany({
|
||||
where,
|
||||
where: scoped,
|
||||
include: {
|
||||
eventOptions: optionInclude ? { include: optionInclude } : true,
|
||||
form: { include: { fields: true } },
|
||||
sessions: SESSIONS_INCLUDE,
|
||||
campus: { select: EVENT_CAMPUS_SELECT },
|
||||
},
|
||||
orderBy: { startDate: 'asc' }
|
||||
});
|
||||
@@ -452,7 +482,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.
|
||||
@@ -496,9 +526,12 @@ const getEventById = async (req, res) => {
|
||||
}
|
||||
|
||||
// Public gating: hide inactive or not-yet-live events from everyone except staff/supervisor/admin,
|
||||
// who need to load inactive events for cashup, editing, etc.
|
||||
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role));
|
||||
// who need to load inactive events for cashup, editing, etc. Campus-scoped staff get the
|
||||
// public view of other campuses' events (and not their notification recipients).
|
||||
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role)) && canView(await scopeFor(req), event.campusId);
|
||||
if (!isStaffOrHigher) {
|
||||
delete event.createdBy;
|
||||
delete event.notifyRecipients;
|
||||
if (event.isActive === false) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
@@ -627,7 +660,7 @@ const getEventIcs = async (req, res) => {
|
||||
|
||||
// Same public-visibility gate as getEventById — a hidden/not-yet-live event's
|
||||
// details shouldn't be enumerable via this route either.
|
||||
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role));
|
||||
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role)) && canView(await scopeFor(req), event.campusId);
|
||||
if (!isStaffOrHigher) {
|
||||
if (event.isActive === false) {
|
||||
res.status(404);
|
||||
@@ -688,6 +721,13 @@ 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;
|
||||
if (campusChanged) {
|
||||
try { await assertCampusAllowed(req, campusPick.campusId); } catch (e) { return res.status(403).json({ message: e.message }); }
|
||||
}
|
||||
|
||||
const data = {
|
||||
title: title || event.title,
|
||||
description: description !== undefined ? description : event.description,
|
||||
@@ -725,6 +765,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 +790,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',
|
||||
|
||||
@@ -151,7 +151,7 @@ const getSummary = async (req, res) => {
|
||||
try {
|
||||
const ids = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
|
||||
const forms = await prisma.feedbackForm.findMany({
|
||||
where: { eventId: { in: ids } },
|
||||
where: { eventId: { in: ids }, ...(await require('../utils/campusScope').listWhere(req, 'event')) },
|
||||
include: {
|
||||
event: { select: { id: true, title: true, startDate: true } },
|
||||
fields: { orderBy: { order: 'asc' } },
|
||||
|
||||
@@ -16,6 +16,7 @@ const listFormResponses = async (req, res) => {
|
||||
const registrationFilter = {};
|
||||
if (eventId) registrationFilter.eventId = String(eventId);
|
||||
if (userId) registrationFilter.userId = String(userId);
|
||||
Object.assign(registrationFilter, await require('../utils/campusScope').listWhere(req, 'event'));
|
||||
if (Object.keys(registrationFilter).length > 0) {
|
||||
// Prisma relation filter requires `is` wrapper
|
||||
where.registration = { is: registrationFilter };
|
||||
|
||||
@@ -351,9 +351,13 @@ const getPayments = async (req, res) => {
|
||||
event: true
|
||||
};
|
||||
|
||||
// Campus-scoped supervisors: payments for events they can see (through the payment's event or
|
||||
// its registration's), so unlinked donations aren't listed for them.
|
||||
const scopeW = require('../utils/campusScope').eventWhere(await require('../utils/campusScope').scopeFor(req));
|
||||
const where = Object.keys(scopeW).length ? { OR: [{ event: scopeW }, { registration: { event: scopeW } }] } : {};
|
||||
const [payments, total] = await prisma.$transaction([
|
||||
prisma.payment.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.payment.count()
|
||||
prisma.payment.findMany({ where, include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.payment.count({ where })
|
||||
]);
|
||||
|
||||
res.json({ data: payments, total, page, limit, pages: Math.ceil(total / limit) });
|
||||
|
||||
@@ -565,8 +565,10 @@ const getRegistrations = async (req, res) => {
|
||||
// Pagination is opt-in via ?page/?limit to keep existing callers (which expect a plain
|
||||
// array of every registration) working unchanged; callers that pass either param get back
|
||||
// the { data, total, page, limit, pages } shape used by /api/payments and /api/users.
|
||||
// Campus-scoped staff: registrations for events they can see.
|
||||
const where = await require('../utils/campusScope').listWhere(req, 'event');
|
||||
if (typeof req.query.page === 'undefined' && typeof req.query.limit === 'undefined') {
|
||||
const registrations = await prisma.registration.findMany({ include });
|
||||
const registrations = await prisma.registration.findMany({ where, include });
|
||||
return res.json(attachComputedTotalsToList(registrations));
|
||||
}
|
||||
|
||||
@@ -575,8 +577,8 @@ const getRegistrations = async (req, res) => {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [registrations, total] = await prisma.$transaction([
|
||||
prisma.registration.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.registration.count()
|
||||
prisma.registration.findMany({ where, include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.registration.count({ where })
|
||||
]);
|
||||
|
||||
res.json({ data: attachComputedTotalsToList(registrations), total, page, limit, pages: Math.ceil(total / limit) });
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
const { listJobs, getJob, updateJob, deleteJob } = require('../utils/scheduledEmails');
|
||||
const prisma = require('../config/db');
|
||||
const { scopeFor, eventWhere } = require('../utils/campusScope');
|
||||
|
||||
/**
|
||||
* The jobs a campus-scoped supervisor may see and change: messages for events they can see, and
|
||||
* broadcasts they scheduled themselves. Everyone else sees every job.
|
||||
*/
|
||||
async function visibleJobs(req, jobs) {
|
||||
const scope = await scopeFor(req);
|
||||
if (!scope) return jobs;
|
||||
const ids = [...new Set(jobs.map(j => j.eventId).filter(Boolean))];
|
||||
const seen = ids.length
|
||||
? new Set((await prisma.event.findMany({ where: { id: { in: ids }, ...eventWhere(scope) }, select: { id: true } })).map(e => e.id))
|
||||
: new Set();
|
||||
return jobs.filter(j => (j.eventId ? seen.has(j.eventId) : j.createdById === req.user.id));
|
||||
}
|
||||
|
||||
// Normalize job for client UI
|
||||
function toClient(job) {
|
||||
@@ -34,7 +50,7 @@ function toClient(job) {
|
||||
const listScheduledEmails = async (req, res) => {
|
||||
try {
|
||||
const { channel } = req.query || {};
|
||||
const raw = listJobs(channel ? { channel: String(channel) } : {});
|
||||
const raw = await visibleJobs(req, listJobs(channel ? { channel: String(channel) } : {}));
|
||||
const filtered = raw
|
||||
// Provide most-relevant first: queued -> sending -> error -> recent sent
|
||||
.sort((a, b) => {
|
||||
@@ -59,7 +75,7 @@ const updateScheduledEmail = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const job = getJob(id);
|
||||
if (!job) return res.status(404).json({ message: 'Job not found' });
|
||||
if (!job || !(await visibleJobs(req, [job])).length) return res.status(404).json({ message: 'Job not found' });
|
||||
if (job.status !== 'queued') return res.status(400).json({ message: 'Only queued jobs can be edited' });
|
||||
|
||||
const { scheduledAt, subject, html, text, message } = req.body || {};
|
||||
@@ -92,7 +108,7 @@ const deleteScheduledEmail = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const job = getJob(id);
|
||||
if (!job) return res.status(404).json({ message: 'Job not found' });
|
||||
if (!job || !(await visibleJobs(req, [job])).length) return res.status(404).json({ message: 'Job not found' });
|
||||
if (job.status !== 'queued') return res.status(400).json({ message: 'Only queued jobs can be deleted' });
|
||||
const ok = deleteJob(id);
|
||||
if (!ok) return res.status(404).json({ message: 'Job not found' });
|
||||
|
||||
@@ -10,9 +10,8 @@ const getSections = async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.query;
|
||||
|
||||
const where = eventId && eventId !== 'all'
|
||||
? { eventId }
|
||||
: undefined;
|
||||
const { withScope, listWhere } = require('../utils/campusScope');
|
||||
const where = withScope(eventId && eventId !== 'all' ? { eventId } : {}, await listWhere(req, 'event'));
|
||||
|
||||
const sections = await prisma.section.findMany({
|
||||
where,
|
||||
|
||||
@@ -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,21 +1,23 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { isDonationLeg } = require('../utils/cashupUtils');
|
||||
const { campusesEnabled, campusWhere, resolveCampusFilter } = require('../utils/campuses');
|
||||
const { scopeFor, eventWhere: scopeEventWhere, withScope } = require('../utils/campusScope');
|
||||
|
||||
// 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,
|
||||
// computed with aggregate queries — never a full payments/events list shipped to the
|
||||
// client just to be reduced down to a couple of numbers.
|
||||
|
||||
async function computeScanStats(userId) {
|
||||
async function computeScanStats(userId, ticketWhere = {}) {
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const whereBase = { scannedAt: { gte: startOfDay } };
|
||||
const whereBase = { scannedAt: { gte: startOfDay }, ...ticketWhere };
|
||||
|
||||
const [totalToday, myToday, lastHour, byStaffRaw] = await Promise.all([
|
||||
prisma.ticketUsage.count({ where: whereBase }),
|
||||
prisma.ticketUsage.count({ where: { ...whereBase, scannedById: userId } }),
|
||||
prisma.ticketUsage.count({ where: { scannedAt: { gte: new Date(Date.now() - 60 * 60 * 1000) } } }),
|
||||
prisma.ticketUsage.count({ where: { scannedAt: { gte: new Date(Date.now() - 60 * 60 * 1000) }, ...ticketWhere } }),
|
||||
prisma.ticketUsage.groupBy({ by: ['scannedById'], where: whereBase, _count: { _all: true } }),
|
||||
]);
|
||||
|
||||
@@ -33,8 +35,9 @@ async function computeScanStats(userId) {
|
||||
};
|
||||
}
|
||||
|
||||
function getRecentScans(limit = 10) {
|
||||
function getRecentScans(limit = 10, ticketWhere = {}) {
|
||||
return prisma.ticketUsage.findMany({
|
||||
where: ticketWhere,
|
||||
orderBy: { scannedAt: 'desc' },
|
||||
take: limit,
|
||||
select: {
|
||||
@@ -53,8 +56,26 @@ 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') together with the user's campus scope
|
||||
* (1.44.0), or empty for church-wide. A payment belongs to a campus through its event, or its
|
||||
* registration's event — so payments with neither (unlinked donations) aren't counted for a
|
||||
* scoped user. EXCLUDE_DONATION_LEGS uses NOT, so the payment OR never collides with it.
|
||||
*/
|
||||
async function campusFilters(value, scope = null) {
|
||||
const enabled = await campusesEnabled();
|
||||
const eventWhere = withScope(campusWhere(enabled ? await resolveCampusFilter(value) : '', { enabled }), scopeEventWhere(scope));
|
||||
if (!Object.keys(eventWhere).length) return { eventWhere: {}, byEvent: {}, payment: {}, usage: {} };
|
||||
return {
|
||||
eventWhere,
|
||||
byEvent: { event: eventWhere },
|
||||
payment: { OR: [{ event: eventWhere }, { registration: { event: eventWhere } }] },
|
||||
usage: { ticket: { event: eventWhere } },
|
||||
};
|
||||
}
|
||||
|
||||
// A donation-application leg (isDonation:false, originalPaymentId set, amount>0) isn't new
|
||||
@@ -64,19 +85,19 @@ const EXCLUDE_DONATION_LEGS = {
|
||||
NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] },
|
||||
};
|
||||
|
||||
async function computePaymentStats({ includeWeekMonth }) {
|
||||
async function computePaymentStats({ includeWeekMonth, paymentWhere = {} }) {
|
||||
const startOfDay = new Date(new Date().setHours(0, 0, 0, 0));
|
||||
|
||||
const queries = [
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: startOfDay }, ...EXCLUDE_DONATION_LEGS } }),
|
||||
prisma.payment.count({ where: { isDonation: true, createdAt: { gte: startOfDay } } }),
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: startOfDay }, ...EXCLUDE_DONATION_LEGS, ...paymentWhere } }),
|
||||
prisma.payment.count({ where: { isDonation: true, createdAt: { gte: startOfDay }, ...paymentWhere } }),
|
||||
];
|
||||
if (includeWeekMonth) {
|
||||
const lastWeek = new Date(startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const lastMonth = new Date(startOfDay.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
queries.push(
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastWeek }, ...EXCLUDE_DONATION_LEGS } }),
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth }, ...EXCLUDE_DONATION_LEGS } }),
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastWeek }, ...EXCLUDE_DONATION_LEGS, ...paymentWhere } }),
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth }, ...EXCLUDE_DONATION_LEGS, ...paymentWhere } }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,9 +119,10 @@ async function computePaymentStats({ includeWeekMonth }) {
|
||||
// @access Private/Staff+
|
||||
const getStaffDashboardStats = async (req, res) => {
|
||||
try {
|
||||
const cf = await campusFilters('', await scopeFor(req));
|
||||
const [scanStats, recentScans] = await Promise.all([
|
||||
computeScanStats(req.user.id),
|
||||
getRecentScans(10),
|
||||
computeScanStats(req.user.id, cf.usage),
|
||||
getRecentScans(10, cf.usage),
|
||||
]);
|
||||
res.json({ scanStats, recentScans });
|
||||
} catch (error) {
|
||||
@@ -113,11 +135,12 @@ const getStaffDashboardStats = async (req, res) => {
|
||||
// @access Private/Supervisor+
|
||||
const getSupervisorDashboardStats = async (req, res) => {
|
||||
try {
|
||||
const cf = await campusFilters('', await scopeFor(req));
|
||||
const [scanStats, recentScans, paymentStats, activeEventsCount] = await Promise.all([
|
||||
computeScanStats(req.user.id),
|
||||
getRecentScans(10),
|
||||
computePaymentStats({ includeWeekMonth: false }),
|
||||
getActiveEventsCount(),
|
||||
computeScanStats(req.user.id, cf.usage),
|
||||
getRecentScans(10, cf.usage),
|
||||
computePaymentStats({ includeWeekMonth: false, paymentWhere: cf.payment }),
|
||||
getActiveEventsCount(cf.eventWhere),
|
||||
]);
|
||||
res.json({ scanStats, recentScans, paymentStats, activeEventsCount });
|
||||
} catch (error) {
|
||||
@@ -207,21 +230,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, await scopeFor(req));
|
||||
|
||||
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 +269,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 +294,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 +320,7 @@ const getOverviewStats = async (req, res) => {
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
campusFilters,
|
||||
getStaffDashboardStats,
|
||||
getSupervisorDashboardStats,
|
||||
getAdminDashboardStats,
|
||||
|
||||
@@ -50,6 +50,8 @@ const getTickets = async (req, res) => {
|
||||
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 100));
|
||||
const skip = (page - 1) * limit;
|
||||
// Campus-scoped supervisors: tickets for events they can see.
|
||||
const scopeW = await require('../utils/campusScope').listWhere(req, 'event');
|
||||
|
||||
const include = {
|
||||
registrationOption: {
|
||||
@@ -66,8 +68,8 @@ const getTickets = async (req, res) => {
|
||||
};
|
||||
|
||||
const [tickets, total] = await prisma.$transaction([
|
||||
prisma.ticket.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.ticket.count()
|
||||
prisma.ticket.findMany({ where: scopeW, include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.ticket.count({ where: scopeW })
|
||||
]);
|
||||
|
||||
res.json({ data: tickets, total, page, limit, pages: Math.ceil(total / limit) });
|
||||
@@ -103,7 +105,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: {
|
||||
@@ -632,9 +634,11 @@ const getRecentScans = async (req, res) => {
|
||||
try {
|
||||
const limit = Math.max(1, Math.min(parseInt(req.query.limit) || 10, 100));
|
||||
const eventId = req.query.eventId || undefined;
|
||||
// Campus-scoped staff: scans of tickets for events they can see.
|
||||
const scopeEv = (await require('../utils/campusScope').listWhere(req, 'event')).event;
|
||||
|
||||
const scans = await prisma.ticketUsage.findMany({
|
||||
where: eventId ? { ticket: { eventId } } : undefined,
|
||||
where: eventId || scopeEv ? { ticket: { ...(eventId ? { eventId } : {}), ...(scopeEv ? { event: scopeEv } : {}) } } : undefined,
|
||||
orderBy: { scannedAt: 'desc' },
|
||||
take: limit,
|
||||
select: {
|
||||
@@ -666,11 +670,14 @@ const getScanStats = async (req, res) => {
|
||||
const eventId = req.query.eventId || undefined;
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
// Campus-scoped staff: scans of tickets for events they can see.
|
||||
const scopeEv = (await require('../utils/campusScope').listWhere(req, 'event')).event;
|
||||
const ticketWhere = eventId || scopeEv ? { ticket: { ...(eventId ? { eventId } : {}), ...(scopeEv ? { event: scopeEv } : {}) } } : {};
|
||||
|
||||
// Total scans today (optionally by event)
|
||||
const whereBase = {
|
||||
scannedAt: { gte: startOfDay },
|
||||
...(eventId ? { ticket: { eventId } } : {})
|
||||
...ticketWhere
|
||||
};
|
||||
|
||||
const [totalToday, myToday, lastHour] = await Promise.all([
|
||||
@@ -678,7 +685,7 @@ const getScanStats = async (req, res) => {
|
||||
prisma.ticketUsage.count({ where: { ...whereBase, scannedById: req.user.id } }),
|
||||
prisma.ticketUsage.count({
|
||||
where: {
|
||||
...(eventId ? { ticket: { eventId } } : {}),
|
||||
...ticketWhere,
|
||||
scannedAt: { gte: new Date(Date.now() - 60 * 60 * 1000) }
|
||||
}
|
||||
})
|
||||
@@ -929,6 +936,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 +996,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
|
||||
}
|
||||
@@ -547,7 +548,9 @@ const getUserProfile = async (req, res) => {
|
||||
// newsPromptPending: they haven't answered the news opt-in question yet (asked once).
|
||||
// noticePromptPending: a privacy notice version they must accept has been published.
|
||||
const noticePromptPending = !req.impersonation && await require('../utils/privacyNotice').noticePromptPending({ noticeVersionAccepted });
|
||||
res.json({ ...rest, twoFactorRequired: requiresTwoFactor(user), newsPromptPending: !newsAskedAt && !req.impersonation, noticePromptPending });
|
||||
// Campus-scoped staff: the campuses they're limited to (null = every campus).
|
||||
const campusScope = (await require('../utils/campusScope').scopeFor(req))?.campusIds || null;
|
||||
res.json({ ...rest, twoFactorRequired: requiresTwoFactor(user), newsPromptPending: !newsAskedAt && !req.impersonation, noticePromptPending, campusScope });
|
||||
} else {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
@@ -571,7 +574,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 +633,7 @@ const updateUserProfile = async (req, res) => {
|
||||
phoneNumber: newPhone,
|
||||
notificationPreference: newPref,
|
||||
twoFactorMethod: newTwoFactorMethod,
|
||||
homeCampusId: newHomeCampusId,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
select: {
|
||||
@@ -633,6 +648,7 @@ const updateUserProfile = async (req, res) => {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true,
|
||||
homeCampusId: true,
|
||||
tokenVersion: true
|
||||
}
|
||||
});
|
||||
@@ -769,7 +785,8 @@ const getUserById = async (req, res) => {
|
||||
phoneNumber: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
scopedCampuses: { select: { id: true, name: true } }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -798,7 +815,7 @@ const updateUser = async (req, res) => {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const { name, email, role, isActive, phoneNumber, password, notificationPreference, twoFactorEnabled } = req.body;
|
||||
const { name, email, role, isActive, phoneNumber, password, notificationPreference, twoFactorEnabled, scopedCampusIds } = req.body;
|
||||
|
||||
const newPhone = phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber;
|
||||
const { resolveNotificationPreference } = require('../utils/notificationPreference');
|
||||
@@ -834,6 +851,21 @@ const updateUser = async (req, res) => {
|
||||
data.tokenVersion = { increment: 1 };
|
||||
}
|
||||
|
||||
// Campus-scoped staff (utils/campusScope.js): "Limit to campuses" for supervisors and staff.
|
||||
// Admins and users are never limited, so a role change away from staff clears the limit.
|
||||
const before = await prisma.user.findUnique({ where: { id: user.id }, select: { scopedCampuses: { select: { id: true } } } });
|
||||
const beforeIds = before.scopedCampuses.map(c => c.id).sort();
|
||||
let afterIds = beforeIds;
|
||||
if (!['supervisor', 'staff'].includes(data.role)) afterIds = [];
|
||||
else if (Array.isArray(scopedCampusIds)) {
|
||||
const wanted = [...new Set(scopedCampusIds.filter(x => typeof x === 'string' && x))];
|
||||
const found = await prisma.campus.findMany({ where: { id: { in: wanted } }, select: { id: true } });
|
||||
if (found.length !== wanted.length) { res.status(400); throw new Error('One of those campuses doesn\'t exist.'); }
|
||||
afterIds = wanted.sort();
|
||||
}
|
||||
const scopeChanged = afterIds.join(',') !== beforeIds.join(',');
|
||||
if (scopeChanged) data.scopedCampuses = { set: afterIds.map(id => ({ id })) };
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data,
|
||||
@@ -847,9 +879,13 @@ const updateUser = async (req, res) => {
|
||||
twoFactorEnabled: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
scopedCampuses: { select: { id: true, name: true } }
|
||||
}
|
||||
});
|
||||
if (scopeChanged) {
|
||||
require('../utils/adminAudit').logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'user_campus_scope_changed', targetType: 'User', targetId: updatedUser.id, metadata: { from: beforeIds, to: afterIds }, ip: getClientIp(req) });
|
||||
}
|
||||
|
||||
if (resetTwoFactor || data.password) {
|
||||
await clearTrustedDevices(updatedUser.id);
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
* it, so a licence can't be faked by editing the database. The matching private key exists
|
||||
* only on the Hub server (events-hub repo, `npm run generate-keys`).
|
||||
*
|
||||
* Set PRODUCTION_PUBLIC_KEY to the key the production Hub prints, before the release that ships
|
||||
* the Hub connection. HUB_PUBLIC_KEY (env) overrides it — for a local Hub while developing.
|
||||
* PRODUCTION_PUBLIC_KEY is the key the production Hub prints (also served at /api/public-key); if
|
||||
* the Hub key is regenerated, update it here. HUB_PUBLIC_KEY (env) overrides it — for a local Hub.
|
||||
*/
|
||||
|
||||
const PRODUCTION_PUBLIC_KEY = null;
|
||||
const PRODUCTION_PUBLIC_KEY = "MCowBQYDK2VwAyEAcKpEo/JdS96wR3r9yh8uFUxBlzPUd2YKbC60fNRUhoU=";
|
||||
|
||||
/**
|
||||
* A public key as proper PEM, however it was pasted: with real line breaks, with "\n", all on
|
||||
@@ -26,7 +26,7 @@ function normalisePem(input) {
|
||||
}
|
||||
|
||||
function hubPublicKey() {
|
||||
return normalisePem(process.env.HUB_PUBLIC_KEY) || PRODUCTION_PUBLIC_KEY;
|
||||
return normalisePem(process.env.HUB_PUBLIC_KEY) || normalisePem(PRODUCTION_PUBLIC_KEY);
|
||||
}
|
||||
|
||||
module.exports = { hubPublicKey, normalisePem, PRODUCTION_PUBLIC_KEY };
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+20
-3
@@ -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);
|
||||
@@ -517,13 +518,13 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[
|
||||
{ status:200, desc:'Success', body:{ exists:true, hasEmail:true, hasPhone:false }},
|
||||
]},
|
||||
{ method:'GET', path:'/api/users/:id', auth:'admin', desc:'Get a single user by ID',
|
||||
{ method:'GET', path:'/api/users/:id', auth:'admin', desc:'Get a single user by ID (with scopedCampuses)',
|
||||
pathParams:{ ':id':'User UUID' },
|
||||
responses:[
|
||||
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', isActive:true, phoneNumber:'+27821234567', notificationPreference:'email' }},
|
||||
{ status:404, desc:'Not found', body:{ message:'User not found' }},
|
||||
]},
|
||||
{ method:'PUT', path:'/api/users/:id', auth:'admin', desc:'Update any user (including role)',
|
||||
{ method:'PUT', path:'/api/users/:id', auth:'admin', desc:'Update any user (including role). scopedCampusIds limits a supervisor/staff member to those campuses (empty = every campus; audited)',
|
||||
pathParams:{ ':id':'User UUID' },
|
||||
request:{ body:{ role:'supervisor', isActive:true, name:'Jane Doe' }},
|
||||
responses:[
|
||||
@@ -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' },
|
||||
|
||||
@@ -5,18 +5,19 @@ const {
|
||||
assign, autoAssign, clearAssignments, getSetLists, getMyGroups,
|
||||
} = require('../controllers/allocationController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, campusIndependent } = require('../utils/campusScope');
|
||||
|
||||
router.get('/my-groups', protect, staff, getMyGroups);
|
||||
router.get('/event/:eventId', protect, supervisor, getBoard);
|
||||
router.post('/sets', protect, supervisor, createSet);
|
||||
router.put('/sets/:id', protect, supervisor, updateSet);
|
||||
router.delete('/sets/:id', protect, supervisor, deleteSet);
|
||||
router.get('/sets/:id/lists', protect, supervisor, getSetLists);
|
||||
router.post('/sets/:id/groups', protect, supervisor, createGroup);
|
||||
router.put('/sets/:id/assignments', protect, supervisor, assign);
|
||||
router.delete('/sets/:id/assignments', protect, supervisor, clearAssignments);
|
||||
router.post('/sets/:id/auto', protect, supervisor, autoAssign);
|
||||
router.put('/groups/:id', protect, supervisor, updateGroup);
|
||||
router.delete('/groups/:id', protect, supervisor, deleteGroup);
|
||||
router.get('/my-groups', protect, staff, campusIndependent('only the groups the signed-in person leads'), getMyGroups);
|
||||
router.get('/event/:eventId', protect, supervisor, scopeEvent(), getBoard);
|
||||
router.post('/sets', protect, supervisor, scopeEvent({ from: req => req.body?.eventId }), createSet);
|
||||
router.put('/sets/:id', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), updateSet);
|
||||
router.delete('/sets/:id', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), deleteSet);
|
||||
router.get('/sets/:id/lists', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), getSetLists);
|
||||
router.post('/sets/:id/groups', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), createGroup);
|
||||
router.put('/sets/:id/assignments', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), assign);
|
||||
router.delete('/sets/:id/assignments', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), clearAssignments);
|
||||
router.post('/sets/:id/auto', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), autoAssign);
|
||||
router.put('/groups/:id', protect, supervisor, scopeEvent({ resolve: 'allocationGroup' }), updateGroup);
|
||||
router.delete('/groups/:id', protect, supervisor, scopeEvent({ resolve: 'allocationGroup' }), deleteGroup);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -3,6 +3,7 @@ const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const { trackStage, getFunnel } = require('../controllers/analyticsController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeList } = require('../utils/campusScope');
|
||||
|
||||
const trackLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
@@ -13,6 +14,6 @@ const trackLimiter = rateLimit({
|
||||
});
|
||||
|
||||
router.post('/track', trackLimiter, trackStage);
|
||||
router.get('/funnel', protect, supervisor, getFunnel);
|
||||
router.get('/funnel', protect, supervisor, scopeList, getFunnel);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -6,20 +6,21 @@ const {
|
||||
myCertificates, downloadCertificate, verifyCertificate,
|
||||
} = require('../controllers/attendanceController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, campusIndependent } = require('../utils/campusScope');
|
||||
|
||||
// Certificates — fixed paths first so they aren't taken as an :eventId
|
||||
router.get('/certificates/verify/:code', verifyCertificate);
|
||||
router.get('/certificates/mine', protect, myCertificates);
|
||||
router.post('/certificates/preview', protect, supervisor, previewCertificate);
|
||||
router.post('/certificates/preview', protect, supervisor, campusIndependent('previews a certificate template from the request body'), previewCertificate);
|
||||
router.get('/certificates/:id/pdf', protect, downloadCertificate);
|
||||
router.post('/certificates/:id/revoke', protect, supervisor, revokeCertificate);
|
||||
router.post('/certificates/:id/revoke', protect, supervisor, scopeEvent({ resolve: 'certificate' }), revokeCertificate);
|
||||
|
||||
// Attendance register
|
||||
router.get('/:eventId', protect, staff, getRegister);
|
||||
router.get('/:eventId/export', protect, staff, exportRegister);
|
||||
router.post('/:eventId/mark', protect, supervisor, markAttendance);
|
||||
router.post('/:eventId/certificates', protect, supervisor, issueCertificates);
|
||||
router.post('/:eventId/certificates/send', protect, supervisor, sendCertificatesHandler);
|
||||
router.get('/:eventId/certificates.pdf', protect, supervisor, downloadEventCertificates);
|
||||
router.get('/:eventId', protect, staff, scopeEvent(), getRegister);
|
||||
router.get('/:eventId/export', protect, staff, scopeEvent(), exportRegister);
|
||||
router.post('/:eventId/mark', protect, supervisor, scopeEvent(), markAttendance);
|
||||
router.post('/:eventId/certificates', protect, supervisor, scopeEvent(), issueCertificates);
|
||||
router.post('/:eventId/certificates/send', protect, supervisor, scopeEvent(), sendCertificatesHandler);
|
||||
router.get('/:eventId/certificates.pdf', protect, supervisor, scopeEvent(), downloadEventCertificates);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,8 +2,9 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { scheduleAutomations } = require('../controllers/automationController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
// Schedule multiple automation emails for an event
|
||||
router.post('/schedule', protect, supervisor, scheduleAutomations);
|
||||
router.post('/schedule', protect, supervisor, scopeEvent({ from: req => req.body?.eventId }), scheduleAutomations);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,8 +2,9 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getBanner, setBanner } = require('../controllers/bannerController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { unscopedOnly } = require('../utils/campusScope');
|
||||
|
||||
router.get('/', getBanner);
|
||||
router.post('/', protect, supervisor, setBanner);
|
||||
router.post('/', protect, supervisor, unscopedOnly, setBanner);
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,10 +2,11 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { previewBroadcast, sendBroadcast, scheduleBroadcast } = require('../controllers/broadcastController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeBroadcast } = require('../utils/campusScope');
|
||||
|
||||
// All routes require supervisor (or admin via middleware logic) access
|
||||
router.post('/preview', protect, supervisor, previewBroadcast);
|
||||
router.post('/send', protect, supervisor, sendBroadcast);
|
||||
router.post('/schedule', protect, supervisor, scheduleBroadcast);
|
||||
router.post('/preview', protect, supervisor, scopeBroadcast({ freeformField: 'emails' }), previewBroadcast);
|
||||
router.post('/send', protect, supervisor, scopeBroadcast({ freeformField: 'emails' }), sendBroadcast);
|
||||
router.post('/schedule', protect, supervisor, scopeBroadcast({ freeformField: 'emails' }), scheduleBroadcast);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,13 +2,14 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles } = require('../controllers/bundleController');
|
||||
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
// What a basket gets off before registering (guests included)
|
||||
router.post('/quote', optionalAuth, quoteBundles);
|
||||
|
||||
router.get('/event/:eventId', protect, supervisor, listBundles);
|
||||
router.post('/event/:eventId', protect, supervisor, createBundle);
|
||||
router.put('/:id', protect, supervisor, updateBundle);
|
||||
router.delete('/:id', protect, supervisor, deleteBundle);
|
||||
router.get('/event/:eventId', protect, supervisor, scopeEvent(), listBundles);
|
||||
router.post('/event/:eventId', protect, supervisor, scopeEvent({ need: 'manage' }), createBundle);
|
||||
router.put('/:id', protect, supervisor, scopeEvent({ resolve: 'bundle', need: 'manage' }), updateBundle);
|
||||
router.delete('/:id', protect, supervisor, scopeEvent({ resolve: 'bundle', need: 'manage' }), deleteBundle);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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');
|
||||
const { scopeList } = require('../utils/campusScope');
|
||||
|
||||
// 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, scopeList, getCampusAudience);
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,11 +2,12 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventCashup, getCashByRecordedUser, savePersonCash, saveEventCashupDraft, closeEvent, reopenEvent, resolvePending, getCashupAudit } = require('../controllers/cashupController');
|
||||
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
router.get('/audit', protect, supervisor, getCashupAudit);
|
||||
router.get('/event/:eventId', protect, supervisor, getEventCashup);
|
||||
router.get('/event/:eventId/cash-by-user', protect, supervisor, getCashByRecordedUser);
|
||||
router.put('/event/:eventId/person-cash/:userId', protect, supervisor, savePersonCash);
|
||||
router.get('/audit', protect, supervisor, scopeList, getCashupAudit);
|
||||
router.get('/event/:eventId', protect, supervisor, scopeEvent({ need: 'manage' }), getEventCashup);
|
||||
router.get('/event/:eventId/cash-by-user', protect, supervisor, scopeEvent({ need: 'manage' }), getCashByRecordedUser);
|
||||
router.put('/event/:eventId/person-cash/:userId', protect, supervisor, scopeEvent({ need: 'manage' }), savePersonCash);
|
||||
router.put('/event/:eventId/draft', protect, admin, saveEventCashupDraft);
|
||||
router.post('/event/:eventId/close', protect, admin, closeEvent);
|
||||
router.post('/event/:eventId/reopen', protect, admin, reopenEvent);
|
||||
|
||||
@@ -3,15 +3,16 @@ const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const { getConsent, answerConsent, listEventConsent, resendRequest, resendPending, rerequestConsent, medicalList } = require('../controllers/consentController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
// The token in the link is the parent's only credential, so keep guessing slow.
|
||||
const consentLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
|
||||
|
||||
router.get('/events/:eventId', protect, staff, listEventConsent);
|
||||
router.post('/events/:eventId/resend-pending', protect, supervisor, resendPending);
|
||||
router.post('/events/:eventId/rerequest', protect, supervisor, rerequestConsent);
|
||||
router.get('/events/:eventId/medical', protect, supervisor, medicalList);
|
||||
router.post('/requests/:id/resend', protect, resendRequest);
|
||||
router.get('/events/:eventId', protect, staff, scopeEvent(), listEventConsent);
|
||||
router.post('/events/:eventId/resend-pending', protect, supervisor, scopeEvent(), resendPending);
|
||||
router.post('/events/:eventId/rerequest', protect, supervisor, scopeEvent(), rerequestConsent);
|
||||
router.get('/events/:eventId/medical', protect, supervisor, scopeEvent(), medicalList);
|
||||
router.post('/requests/:id/resend', protect, scopeEvent({ resolve: 'consentRequest', ownerOk: true }), resendRequest);
|
||||
|
||||
// Public consent page
|
||||
router.get('/:token', consentLimiter, getConsent);
|
||||
|
||||
@@ -2,9 +2,10 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventCosts, createEventCost, updateEventCost, deleteEventCost } = require('../controllers/costController');
|
||||
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
// Mounted at /api — paths below match the documented /api/events/:eventId/costs and /api/costs/:id shape
|
||||
router.get('/events/:eventId/costs', protect, supervisor, getEventCosts);
|
||||
router.get('/events/:eventId/costs', protect, supervisor, scopeEvent({ need: 'manage' }), getEventCosts);
|
||||
router.post('/events/:eventId/costs', protect, admin, createEventCost);
|
||||
router.put('/costs/:id', protect, admin, updateEventCost);
|
||||
router.delete('/costs/:id', protect, admin, deleteEventCost);
|
||||
|
||||
@@ -2,14 +2,15 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions } = require('../controllers/couponController');
|
||||
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Check a code against a basket before registering (guests included)
|
||||
router.post('/validate', optionalAuth, validateCode);
|
||||
router.post('/validate', optionalAuth, scopeEvent({ from: req => req.body?.eventId }), validateCode);
|
||||
|
||||
router.get('/', protect, supervisor, listCoupons);
|
||||
router.post('/', protect, supervisor, createCoupon);
|
||||
router.get('/redemptions', protect, supervisor, listRedemptions);
|
||||
router.get('/:id', protect, supervisor, getCoupon);
|
||||
router.put('/:id', protect, supervisor, updateCoupon);
|
||||
router.get('/', protect, supervisor, scopeList, listCoupons);
|
||||
router.post('/', protect, supervisor, scopeList, createCoupon);
|
||||
router.get('/redemptions', protect, supervisor, scopeList, listRedemptions);
|
||||
router.get('/:id', protect, supervisor, scopeList, getCoupon);
|
||||
router.put('/:id', protect, supervisor, scopeList, updateCoupon);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -29,6 +29,7 @@ const {
|
||||
getEventByAlias
|
||||
} = require('../controllers/eventController');
|
||||
const { protect, admin, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Public routes
|
||||
router.get('/', getEvents);
|
||||
@@ -49,40 +50,40 @@ router.get('/:id/ics', optionalAuth, getEventIcs);
|
||||
router.get('/by-alias/:redirectUrl', getEventByAlias);
|
||||
|
||||
// Create/Update/Delete event
|
||||
router.post('/', protect, supervisor, createEvent);
|
||||
router.post('/', protect, supervisor, scopeList, createEvent);
|
||||
router.route('/:id')
|
||||
.put(protect, supervisor, updateEvent)
|
||||
.put(protect, supervisor, scopeEvent({ need: 'manage' }), updateEvent)
|
||||
.delete(protect, admin, deleteEvent);
|
||||
|
||||
// Notification recipients: who gets registration/payment/daily-summary emails for this event
|
||||
router.route('/:id/notify-recipients')
|
||||
.get(protect, supervisor, getEventNotifyRecipients)
|
||||
.put(protect, supervisor, updateEventNotifyRecipients);
|
||||
.get(protect, supervisor, scopeEvent(), getEventNotifyRecipients)
|
||||
.put(protect, supervisor, scopeEvent({ need: 'manage' }), updateEventNotifyRecipients);
|
||||
|
||||
// Attachments routes
|
||||
router.get('/:id/attachments', listEventAttachments); // public read (used by event detail page)
|
||||
router.post('/:id/attachments', protect, supervisor, ...uploadEventAttachment);
|
||||
router.delete('/:eventId/attachments/:attachmentId', protect, supervisor, deleteEventAttachment);
|
||||
router.post('/:id/attachments', protect, supervisor, scopeEvent({ need: 'manage' }), ...uploadEventAttachment);
|
||||
router.delete('/:eventId/attachments/:attachmentId', protect, supervisor, scopeEvent({ need: 'manage' }), deleteEventAttachment);
|
||||
|
||||
// Bulk email attendees
|
||||
router.post('/:id/email-attendees', protect, supervisor, emailEventAttendees);
|
||||
router.post('/:id/email-attendees', protect, supervisor, scopeEvent(), emailEventAttendees);
|
||||
// Schedule bulk email to attendees
|
||||
router.post('/:id/email-attendees/schedule', protect, supervisor, scheduleEmailEventAttendees);
|
||||
router.post('/:id/email-attendees/schedule', protect, supervisor, scopeEvent(), scheduleEmailEventAttendees);
|
||||
// Bulk WhatsApp message to attendees
|
||||
router.post('/:id/whatsapp-attendees', protect, supervisor, whatsappEventAttendees);
|
||||
router.post('/:id/whatsapp-attendees', protect, supervisor, scopeEvent(), whatsappEventAttendees);
|
||||
// Schedule bulk WhatsApp message to attendees
|
||||
router.post('/:id/whatsapp-attendees/schedule', protect, supervisor, scheduleWhatsappEventAttendees);
|
||||
router.post('/:id/whatsapp-attendees/schedule', protect, supervisor, scopeEvent(), scheduleWhatsappEventAttendees);
|
||||
|
||||
// Event options routes
|
||||
router.post('/:id/options', protect, supervisor, createEventOption);
|
||||
router.post('/:id/options', protect, supervisor, scopeEvent({ need: 'manage' }), createEventOption);
|
||||
router.route('/options/:id')
|
||||
.put(protect, supervisor, updateEventOption)
|
||||
.put(protect, supervisor, scopeEvent({ resolve: 'eventOption', need: 'manage' }), updateEventOption)
|
||||
.delete(protect, admin, deleteEventOption);
|
||||
|
||||
// Option variant routes
|
||||
router.post('/options/:id/variants', protect, supervisor, createOptionVariant);
|
||||
router.post('/options/:id/variants', protect, supervisor, scopeEvent({ resolve: 'eventOption', need: 'manage' }), createOptionVariant);
|
||||
router.route('/variants/:id')
|
||||
.put(protect, supervisor, updateOptionVariant)
|
||||
.put(protect, supervisor, scopeEvent({ resolve: 'optionVariant', need: 'manage' }), updateOptionVariant)
|
||||
.delete(protect, admin, deleteOptionVariant);
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,13 +2,14 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventFeedback, saveEventFeedback, deleteEventFeedback, sendNow, getPublicForm, submitPublicForm, getSummary } = require('../controllers/feedbackController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
router.get('/public/:token', getPublicForm);
|
||||
router.post('/public/:token', submitPublicForm);
|
||||
router.get('/summary', protect, supervisor, getSummary);
|
||||
router.get('/event/:eventId', protect, supervisor, getEventFeedback);
|
||||
router.put('/event/:eventId', protect, supervisor, saveEventFeedback);
|
||||
router.delete('/event/:eventId', protect, supervisor, deleteEventFeedback);
|
||||
router.post('/event/:eventId/send-now', protect, supervisor, sendNow);
|
||||
router.get('/summary', protect, supervisor, scopeList, getSummary);
|
||||
router.get('/event/:eventId', protect, supervisor, scopeEvent(), getEventFeedback);
|
||||
router.put('/event/:eventId', protect, supervisor, scopeEvent({ need: 'manage' }), saveEventFeedback);
|
||||
router.delete('/event/:eventId', protect, supervisor, scopeEvent({ need: 'manage' }), deleteEventFeedback);
|
||||
router.post('/event/:eventId/send-now', protect, supervisor, scopeEvent({ need: 'manage' }), sendNow);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,8 +2,9 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listFormResponses } = require('../controllers/formController');
|
||||
const { protect, staff } = require('../middleware/authMiddleware');
|
||||
const { scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Staff or higher can view submitted forms
|
||||
router.get('/responses', protect, staff, listFormResponses);
|
||||
router.get('/responses', protect, staff, scopeList, listFormResponses);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -17,32 +17,38 @@ const {
|
||||
} = require('../controllers/paymentController');
|
||||
const { listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider, listOpenCheckouts, checkCheckoutStatus } = require('../controllers/paymentProvidersController');
|
||||
const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeBody, scopeList, unscopedOnly } = require('../utils/campusScope');
|
||||
|
||||
// Campus-scoped staff: payments follow their registration's or event's campus. Door payments for
|
||||
// church-wide events are allowed; refunds of them aren't (need 'manage'); a payment or donation
|
||||
// not linked to any event is for admins and unscoped supervisors (orphan 'deny').
|
||||
const paymentTarget = scopeBody([['registrationId', 'registration'], ['eventId', 'event']], { orphan: 'deny' });
|
||||
|
||||
// Protected routes
|
||||
router.post('/', protect, supervisor, createPayment);
|
||||
router.post('/', protect, supervisor, paymentTarget, createPayment);
|
||||
router.get('/providers', listPaymentProviders);
|
||||
router.get('/pay/:id', getPayPage);
|
||||
router.post('/pay/:id', choosePayProvider);
|
||||
router.get('/checkouts/open', protect, supervisor, listOpenCheckouts);
|
||||
router.post('/checkouts/check', protect, supervisor, checkCheckoutStatus);
|
||||
router.get('/checkouts/open', protect, supervisor, unscopedOnly, listOpenCheckouts);
|
||||
router.post('/checkouts/check', protect, supervisor, unscopedOnly, checkCheckoutStatus);
|
||||
router.get('/providers/settings', protect, admin, getPaymentProviderSettings);
|
||||
router.put('/providers/settings', protect, admin, updatePaymentProviderSettings);
|
||||
router.post('/checkout', protect, createCheckout);
|
||||
router.post('/checkout/send', protect, supervisor, sendPaymentLink);
|
||||
router.post('/checkout/send', protect, supervisor, scopeBody([['registrationId', 'registration']]), sendPaymentLink);
|
||||
// Older names, kept for existing clients
|
||||
router.post('/yoco-checkout', protect, createCheckout);
|
||||
router.post('/yoco-checkout/send', protect, supervisor, sendPaymentLink);
|
||||
router.post('/yoco-checkout/send', protect, supervisor, scopeBody([['registrationId', 'registration']]), sendPaymentLink);
|
||||
router.get('/mypayments', protect, getUserPayments);
|
||||
router.post('/:id/send-receipt', protect, sendReceipt);
|
||||
router.get('/:id', protect, getPaymentById);
|
||||
router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
|
||||
router.post('/:id/send-receipt', protect, scopeEvent({ resolve: 'payment', ownerOk: true }), sendReceipt);
|
||||
router.get('/:id', protect, scopeEvent({ resolve: 'payment', ownerOk: true }), getPaymentById);
|
||||
router.get('/registration/:registrationId', protect, scopeEvent({ resolve: 'registration', from: req => req.params.registrationId, ownerOk: true }), getPaymentsByRegistration);
|
||||
|
||||
// Admin/Staff routes
|
||||
router.get('/', protect, supervisor, getPayments);
|
||||
router.get('/event/:eventId', protect, staff, getPaymentsByEvent);
|
||||
router.put('/assign-donation', protect, supervisor, assignDonationToRegistration);
|
||||
router.post('/unassign-donation', protect, supervisor, unassignDonationFromRegistration);
|
||||
router.post('/refund', protect, supervisor, createRefund);
|
||||
router.get('/', protect, supervisor, scopeList, getPayments);
|
||||
router.get('/event/:eventId', protect, staff, scopeEvent(), getPaymentsByEvent);
|
||||
router.put('/assign-donation', protect, supervisor, scopeBody([['paymentId', 'payment']], { orphan: 'deny' }), scopeBody([['registrationId', 'registration']]), assignDonationToRegistration);
|
||||
router.post('/unassign-donation', protect, supervisor, scopeBody([['legId', 'payment']], { orphan: 'deny' }), unassignDonationFromRegistration);
|
||||
router.post('/refund', protect, supervisor, scopeBody([['paymentId', 'payment'], ['registrationId', 'registration']], { need: 'manage', orphan: 'deny' }), createRefund);
|
||||
router.get('/admin/stats', protect, admin, getPaymentStats);
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,19 +2,20 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getAllProviderTransactions, getUnreconciledProviderTransactions, reconcileProviderTransaction, ignoreProviderTransaction } = require('../controllers/providerTransactionsController');
|
||||
const { protect, supervisor} = require('../middleware/authMiddleware');
|
||||
const { unscopedOnly } = require('../utils/campusScope');
|
||||
|
||||
// Mounted at /api/provider-transactions (and /api/yoco-transactions, the older name).
|
||||
|
||||
// GET all payment provider transactions (admin/supervisor)
|
||||
router.get('/', protect, supervisor, getAllProviderTransactions);
|
||||
router.get('/', protect, supervisor, unscopedOnly, getAllProviderTransactions);
|
||||
|
||||
// GET all unreconciled transactions (admin/supervisor)
|
||||
router.get('/unreconciled', protect, supervisor, getUnreconciledProviderTransactions);
|
||||
router.get('/unreconciled', protect, supervisor, unscopedOnly, getUnreconciledProviderTransactions);
|
||||
|
||||
// POST reconcile a transaction
|
||||
router.post('/:id/reconcile', protect, supervisor, reconcileProviderTransaction);
|
||||
router.post('/:id/reconcile', protect, supervisor, unscopedOnly, reconcileProviderTransaction);
|
||||
|
||||
// POST ignore a transaction
|
||||
router.post('/:id/ignore', protect, supervisor, ignoreProviderTransaction);
|
||||
router.post('/:id/ignore', protect, supervisor, unscopedOnly, ignoreProviderTransaction);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -17,29 +17,33 @@ const {
|
||||
updateAttendees, sendAttendeeFormLink,
|
||||
} = require('../controllers/registrationController');
|
||||
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Campus-scoped staff (utils/campusScope.js). Routes open to the registration's owner as well
|
||||
// as to staff check staff against the registration's event, and let the owner through (ownerOk).
|
||||
|
||||
// Registration - auth optional when event doesn't require it
|
||||
router.post('/', optionalAuth, createRegistration);
|
||||
router.get('/myregistrations', protect, getUserRegistrations);
|
||||
router.put('/:id/options', protect, updateRegistrationOptions);
|
||||
router.delete('/:id', protect, cancelRegistration);
|
||||
router.post('/:id/send-invoice', protect, sendInvoice);
|
||||
router.post('/:id/coupon', protect, applyCoupon);
|
||||
router.delete('/:id/coupon', protect, staff, removeCoupon);
|
||||
router.put('/:id/attendees', protect, updateAttendees);
|
||||
router.post('/:id/attendees/:attendeeId/send-form-link', protect, sendAttendeeFormLink);
|
||||
router.put('/:id/options', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), updateRegistrationOptions);
|
||||
router.delete('/:id', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), cancelRegistration);
|
||||
router.post('/:id/send-invoice', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), sendInvoice);
|
||||
router.post('/:id/coupon', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), applyCoupon);
|
||||
router.delete('/:id/coupon', protect, staff, scopeEvent({ resolve: 'registration' }), removeCoupon);
|
||||
router.put('/:id/attendees', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), updateAttendees);
|
||||
router.post('/:id/attendees/:attendeeId/send-form-link', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), sendAttendeeFormLink);
|
||||
|
||||
// Registration detail + forms — optionalAuth so guests can access with just the registrationId
|
||||
router.get('/:id', optionalAuth, getRegistrationById);
|
||||
router.get('/:id/forms/draft', optionalAuth, getFormDraft);
|
||||
router.put('/:id/forms/draft', optionalAuth, saveFormDraft);
|
||||
router.post('/:id/forms/responses', optionalAuth, submitFormResponses);
|
||||
router.put('/:id/forms/responses', protect, supervisor, replaceFormResponses);
|
||||
router.get('/:id', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), getRegistrationById);
|
||||
router.get('/:id/forms/draft', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), getFormDraft);
|
||||
router.put('/:id/forms/draft', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), saveFormDraft);
|
||||
router.post('/:id/forms/responses', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), submitFormResponses);
|
||||
router.put('/:id/forms/responses', protect, supervisor, scopeEvent({ resolve: 'registration' }), replaceFormResponses);
|
||||
|
||||
// Admin/Staff routes
|
||||
router.get('/', protect, staff, getRegistrations);
|
||||
router.put('/:id', protect, staff, updateRegistrationStatus);
|
||||
router.get('/event/:eventId', protect, staff, getRegistrationsByEvent);
|
||||
router.post('/manual', protect, supervisor, createManualRegistration);
|
||||
router.get('/', protect, staff, scopeList, getRegistrations);
|
||||
router.put('/:id', protect, staff, scopeEvent({ resolve: 'registration' }), updateRegistrationStatus);
|
||||
router.get('/event/:eventId', protect, staff, scopeEvent(), getRegistrationsByEvent);
|
||||
router.post('/manual', protect, supervisor, scopeEvent({ from: req => req.body?.eventId }), createManualRegistration);
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,14 +2,15 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listLocks, createLock, getSnapshot, syncLock, releaseLock, forceRelease, listReports, reviewReport } = require('../controllers/scanLockController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
router.get('/', protect, staff, listLocks);
|
||||
router.post('/', protect, staff, createLock);
|
||||
router.get('/reports', protect, supervisor, listReports);
|
||||
router.post('/reports/:id/review', protect, supervisor, reviewReport);
|
||||
router.get('/:id/snapshot', protect, staff, getSnapshot);
|
||||
router.post('/:id/sync', protect, staff, syncLock);
|
||||
router.post('/:id/force-release', protect, supervisor, forceRelease);
|
||||
router.delete('/:id', protect, staff, releaseLock);
|
||||
router.get('/', protect, staff, scopeEvent({ from: req => req.query.eventId }), listLocks);
|
||||
router.post('/', protect, staff, scopeEvent({ from: req => req.body?.eventId }), createLock);
|
||||
router.get('/reports', protect, supervisor, scopeEvent({ from: req => req.query.eventId }), listReports);
|
||||
router.post('/reports/:id/review', protect, supervisor, scopeEvent({ resolve: 'syncReport' }), reviewReport);
|
||||
router.get('/:id/snapshot', protect, staff, scopeEvent({ resolve: 'scanLock' }), getSnapshot);
|
||||
router.post('/:id/sync', protect, staff, scopeEvent({ resolve: 'scanLock' }), syncLock);
|
||||
router.post('/:id/force-release', protect, supervisor, scopeEvent({ resolve: 'scanLock' }), forceRelease);
|
||||
router.delete('/:id', protect, staff, scopeEvent({ resolve: 'scanLock' }), releaseLock);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,10 +2,11 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listScheduledEmails, updateScheduledEmail, deleteScheduledEmail } = require('../controllers/scheduledEmailsController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeList } = require('../utils/campusScope');
|
||||
|
||||
// All routes require supervisor (or admin via middleware logic) access
|
||||
router.get('/', protect, supervisor, listScheduledEmails);
|
||||
router.patch('/:id', protect, supervisor, updateScheduledEmail);
|
||||
router.delete('/:id', protect, supervisor, deleteScheduledEmail);
|
||||
router.get('/', protect, supervisor, scopeList, listScheduledEmails);
|
||||
router.patch('/:id', protect, supervisor, scopeList, updateScheduledEmail);
|
||||
router.delete('/:id', protect, supervisor, scopeList, deleteScheduledEmail);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -7,10 +7,11 @@ const {
|
||||
updateSection
|
||||
} = require('../controllers/sectionController');
|
||||
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
router.get('/', protect, staff, getSections);
|
||||
router.post('/', protect, supervisor, createSection);
|
||||
router.get('/', protect, staff, scopeList, getSections);
|
||||
router.post('/', protect, supervisor, scopeEvent({ from: req => req.body?.eventId, need: 'manage' }), createSection);
|
||||
router.delete('/:id', protect, admin, deleteSection);
|
||||
router.put('/:id', protect, supervisor, updateSection);
|
||||
router.put('/:id', protect, supervisor, scopeEvent({ resolve: 'section', need: 'manage' }), updateSection);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,9 +2,10 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventSponsorship, createSponsorCheckout, getPool } = require('../controllers/sponsorshipController');
|
||||
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
router.get('/event/:eventId', getEventSponsorship);
|
||||
router.get('/event/:eventId/pool', protect, supervisor, getPool);
|
||||
router.get('/event/:eventId/pool', protect, supervisor, scopeEvent(), getPool);
|
||||
router.post('/checkout', optionalAuth, createSponsorCheckout);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,16 +2,17 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getStaffDashboardStats, getSupervisorDashboardStats, getAdminDashboardStats, getOverviewStats } = require('../controllers/statsController');
|
||||
const { protect, staff, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
const { scopeList } = require('../utils/campusScope');
|
||||
|
||||
// One endpoint per dashboard — each returns exactly what that dashboard renders in a
|
||||
// single response instead of the dashboard making several separate calls (and, previously,
|
||||
// pulling full payments/events lists just to reduce them to a couple of numbers).
|
||||
router.get('/staff', protect, staff, getStaffDashboardStats);
|
||||
router.get('/supervisor', protect, supervisor, getSupervisorDashboardStats);
|
||||
router.get('/staff', protect, staff, scopeList, getStaffDashboardStats);
|
||||
router.get('/supervisor', protect, supervisor, scopeList, getSupervisorDashboardStats);
|
||||
router.get('/admin', protect, admin, getAdminDashboardStats);
|
||||
|
||||
// Month-over-month KPIs + trend + top events, shared by the Admin and Supervisor dashboards.
|
||||
// Staff never sees financial data, so this has no staff-accessible route.
|
||||
router.get('/overview', protect, supervisor, getOverviewStats);
|
||||
router.get('/overview', protect, supervisor, scopeList, getOverviewStats);
|
||||
|
||||
module.exports = router;
|
||||
@@ -22,31 +22,34 @@ const {
|
||||
getTicketTransfers
|
||||
} = require('../controllers/ticketController');
|
||||
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeBody, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Campus-scoped staff (utils/campusScope.js). The scanner names the campus of another campus's ticket.
|
||||
|
||||
// Protected routes
|
||||
router.get('/mytickets', protect, getUserTickets);
|
||||
router.get('/:id', protect, getTicketById);
|
||||
router.get('/:id', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), getTicketById);
|
||||
router.post('/email', protect, emailTickets);
|
||||
router.post('/:id/transfer', protect, transferTicketHandler);
|
||||
router.delete('/:id/transfer', protect, cancelTransferHandler);
|
||||
router.post('/:id/transfer', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), transferTicketHandler);
|
||||
router.delete('/:id/transfer', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), cancelTransferHandler);
|
||||
// Transfer links (public: the person the ticket is offered to)
|
||||
router.get('/transfer-offer/:token', getTransferOffer);
|
||||
router.post('/transfer-offer/:token/accept', acceptTransferOffer);
|
||||
router.post('/transfer-offer/:token/decline', declineTransferOffer);
|
||||
router.get('/:id/transfers', protect, getTicketTransfers);
|
||||
router.get('/:id/transfers', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), getTicketTransfers);
|
||||
|
||||
// Staff routes
|
||||
router.post('/send-to', protect, staff, sendTicketsTo);
|
||||
router.get('/scan-preview/:qrCode', protect, staff, getScanPreview);
|
||||
router.get('/qr/:qrCode', protect, staff, getTicketByQrCode);
|
||||
router.post('/scan/:qrCode', protect, staff, scanTicket);
|
||||
router.get('/event/:eventId', protect, staff, getTicketsByEvent);
|
||||
router.get('/scans/recent', protect, staff, getRecentScans);
|
||||
router.get('/scans/stats', protect, staff, getScanStats);
|
||||
router.post('/send-to', protect, staff, scopeBody([['registrationId', 'registration'], ['ticketIds', 'ticket']]), sendTicketsTo);
|
||||
router.get('/scan-preview/:qrCode', protect, staff, scopeEvent({ resolve: 'ticketQr', from: req => req.params.qrCode, nameCampus: true }), getScanPreview);
|
||||
router.get('/qr/:qrCode', protect, staff, scopeEvent({ resolve: 'ticketQr', from: req => req.params.qrCode, nameCampus: true }), getTicketByQrCode);
|
||||
router.post('/scan/:qrCode', protect, staff, scopeEvent({ resolve: 'ticketQr', from: req => req.params.qrCode, nameCampus: true }), scanTicket);
|
||||
router.get('/event/:eventId', protect, staff, scopeEvent(), getTicketsByEvent);
|
||||
router.get('/scans/recent', protect, staff, scopeList, getRecentScans);
|
||||
router.get('/scans/stats', protect, staff, scopeList, getScanStats);
|
||||
|
||||
// Admin routes
|
||||
router.post('/generate', protect, supervisor, generateTickets);
|
||||
router.get('/', protect, supervisor, getTickets);
|
||||
router.post('/generate', protect, supervisor, scopeBody([['registrationId', 'registration']]), generateTickets);
|
||||
router.get('/', protect, supervisor, scopeList, getTickets);
|
||||
router.put('/email-sent', protect, admin, markTicketsAsEmailSent);
|
||||
|
||||
module.exports = router;
|
||||
@@ -3,12 +3,13 @@ const router = express.Router();
|
||||
|
||||
const { upload, uploadEventImage, uploadLogo, uploadLogoImage, uploadFavicon, uploadFaviconImage } = require('../controllers/uploadController');
|
||||
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
const { campusIndependent } = require('../utils/campusScope');
|
||||
const prisma = require('../config/db');
|
||||
|
||||
// @route POST /api/uploads/event-image
|
||||
// @desc Upload an image for an event
|
||||
// @access Private (Supervisor+ recommended)
|
||||
router.post('/event-image', protect, supervisor, (req, res, next) => {
|
||||
router.post('/event-image', protect, supervisor, campusIndependent('stores an image file; the event it goes on is checked when the event is saved'), (req, res, next) => {
|
||||
upload.single('image')(req, res, (err) => {
|
||||
if (err) {
|
||||
req.multerError = err;
|
||||
|
||||
@@ -26,6 +26,7 @@ const {
|
||||
getMyActivity,
|
||||
} = require('../controllers/userController');
|
||||
const { protect, admin, supervisor, loginLimiter} = require('../middleware/authMiddleware');
|
||||
const { campusIndependent } = require('../utils/campusScope');
|
||||
|
||||
// Public routes
|
||||
router.post('/', registerUser);
|
||||
@@ -51,9 +52,9 @@ router.post('/2fa/disable', protect, loginLimiter, disableTwoFactor);
|
||||
|
||||
// Admin routes
|
||||
router.route('/')
|
||||
.get(protect, supervisor, getUsers);
|
||||
.get(protect, supervisor, campusIndependent('accounts are shared across campuses'), getUsers);
|
||||
|
||||
router.get('/check-exists', protect, supervisor, checkUserExists);
|
||||
router.get('/check-exists', protect, supervisor, campusIndependent('accounts are shared across campuses'), checkUserExists);
|
||||
|
||||
router.route('/:id')
|
||||
.get(protect, admin, getUserById)
|
||||
|
||||
@@ -3,6 +3,7 @@ const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const { joinWaitlist, myWaitlist, leaveWaitlist, claimInfo, eventWaitlist, offerManually, moveEntry, removeEntry } = require('../controllers/waitlistController');
|
||||
const { protect, staff, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
// Claim links are credentials, so keep guessing slow.
|
||||
const claimLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
|
||||
@@ -10,10 +11,10 @@ const claimLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { messag
|
||||
router.post('/', optionalAuth, joinWaitlist);
|
||||
router.get('/mine', protect, myWaitlist);
|
||||
router.get('/claim/:token', claimLimiter, claimInfo);
|
||||
router.get('/events/:eventId', protect, staff, eventWaitlist);
|
||||
router.post('/:id/offer', protect, supervisor, offerManually);
|
||||
router.post('/:id/move', protect, supervisor, moveEntry);
|
||||
router.post('/:id/remove', protect, supervisor, removeEntry);
|
||||
router.get('/events/:eventId', protect, staff, scopeEvent(), eventWaitlist);
|
||||
router.post('/:id/offer', protect, supervisor, scopeEvent({ resolve: 'waitlistEntry' }), offerManually);
|
||||
router.post('/:id/move', protect, supervisor, scopeEvent({ resolve: 'waitlistEntry' }), moveEntry);
|
||||
router.post('/:id/remove', protect, supervisor, scopeEvent({ resolve: 'waitlistEntry' }), removeEntry);
|
||||
router.delete('/:id', protect, leaveWaitlist);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,9 +2,10 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { previewWhatsAppBroadcast, sendWhatsAppBroadcast, scheduleWhatsAppBroadcast } = require('../controllers/whatsappBroadcastController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeBroadcast } = require('../utils/campusScope');
|
||||
|
||||
router.post('/preview', protect, supervisor, previewWhatsAppBroadcast);
|
||||
router.post('/send', protect, supervisor, sendWhatsAppBroadcast);
|
||||
router.post('/schedule', protect, supervisor, scheduleWhatsAppBroadcast);
|
||||
router.post('/preview', protect, supervisor, scopeBroadcast({ freeformField: 'phones' }), previewWhatsAppBroadcast);
|
||||
router.post('/send', protect, supervisor, scopeBroadcast({ freeformField: 'phones' }), sendWhatsAppBroadcast);
|
||||
router.post('/schedule', protect, supervisor, scopeBroadcast({ freeformField: 'phones' }), scheduleWhatsAppBroadcast);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Campus-scoped staff (1.44.0). A supervisor or staff member can be limited to some campuses
|
||||
* (Admin → Users → "Limit to campuses"). They then see their campuses' events plus events for
|
||||
* all campuses (Event.campusId null) — and can work on those church-wide events (view, scan,
|
||||
* door payments, manual registrations) but not set them up, cash them up or refund them.
|
||||
*
|
||||
* Unscoped (no limits) = everything, as before. Admins are never scoped, and scopes are ignored
|
||||
* while campuses are off (Site settings → Campuses).
|
||||
*
|
||||
* How a route is checked — every staff/supervisor route carries one of these markers, and
|
||||
* tests/campusScopeRoutes.test.js fails on any route that doesn't:
|
||||
* scopeEvent({ resolve, from, need }) the route works on one event (or a record of one)
|
||||
* scopeList the controller filters (listWhere) or checks it itself
|
||||
* unscopedOnly admins and unscoped supervisors only
|
||||
* campusIndependent('reason') nothing to do with any event's campus
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { campusesEnabled } = require('./campuses');
|
||||
|
||||
const STAFF_ROLES = new Set(['supervisor', 'staff']);
|
||||
|
||||
/**
|
||||
* The scope of the signed-in user: null (unscoped) or { campusIds, names }. Worked out once
|
||||
* per request.
|
||||
*/
|
||||
async function scopeFor(req) {
|
||||
if (req._campusScope !== undefined) return req._campusScope;
|
||||
let scope = null;
|
||||
if (req.user && STAFF_ROLES.has(req.user.role) && await campusesEnabled()) {
|
||||
const row = await prisma.user.findUnique({ where: { id: req.user.id }, select: { scopedCampuses: { select: { id: true, name: true } } } });
|
||||
const campuses = row?.scopedCampuses || [];
|
||||
if (campuses.length) scope = { campusIds: campuses.map(c => c.id), names: campuses.map(c => c.name) };
|
||||
}
|
||||
req._campusScope = scope;
|
||||
return scope;
|
||||
}
|
||||
|
||||
/** The `where` on events for a scope: its campuses plus church-wide events. {} when unscoped. */
|
||||
function eventWhere(scope) {
|
||||
if (!scope) return {};
|
||||
return { OR: [{ campusId: { in: scope.campusIds } }, { campusId: null }] };
|
||||
}
|
||||
|
||||
/** The `where` on events a scope may manage: only its campuses' (not church-wide). {} when unscoped. */
|
||||
function manageWhere(scope) {
|
||||
if (!scope) return {};
|
||||
return { campusId: { in: scope.campusIds } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The same, through a relation path: relWhere(scope, 'event') → { event: {...} },
|
||||
* relWhere(scope, 'registration.event') → { registration: { event: {...} } }.
|
||||
*/
|
||||
function relWhere(scope, path) {
|
||||
if (!scope) return {};
|
||||
return path.split('.').reverse().reduce((inner, key) => ({ [key]: inner }), eventWhere(scope));
|
||||
}
|
||||
|
||||
/** Can this scope see/work on an event at `campusId` (null = all campuses)? */
|
||||
function canView(scope, campusId) {
|
||||
return !scope || campusId == null || scope.campusIds.includes(campusId);
|
||||
}
|
||||
|
||||
/** Can this scope set up, cash up or refund an event at `campusId`? Not church-wide ones. */
|
||||
function canManage(scope, campusId) {
|
||||
return !scope || (campusId != null && scope.campusIds.includes(campusId));
|
||||
}
|
||||
|
||||
// id → { id, campusId, campusName } of the event a record belongs to, or null if there's no such
|
||||
// record. The event select is shared so every resolver answers the same shape.
|
||||
// `owners` are the users the record belongs to, for scopeEvent({ ownerOk }).
|
||||
const EV = { select: { id: true, campusId: true, campus: { select: { name: true } } } };
|
||||
const shape = (ev, owners = []) => (ev ? { id: ev.id, campusId: ev.campusId, campusName: ev.campus?.name || null, owners: owners.filter(Boolean) } : null);
|
||||
|
||||
const RESOLVERS = {
|
||||
event: async id => shape(await prisma.event.findUnique({ where: { id }, ...EV })),
|
||||
registration: async id => {
|
||||
const r = await prisma.registration.findUnique({ where: { id }, select: { userId: true, event: EV } });
|
||||
return shape(r?.event, [r?.userId]);
|
||||
},
|
||||
ticket: async id => {
|
||||
const t = await prisma.ticket.findUnique({ where: { id }, select: { userId: true, holderUserId: true, event: EV } });
|
||||
return shape(t?.event, [t?.userId, t?.holderUserId]);
|
||||
},
|
||||
ticketQr: async qrCode => shape((await prisma.ticket.findUnique({ where: { qrCode }, select: { event: EV } }))?.event),
|
||||
payment: async id => {
|
||||
const p = await prisma.payment.findUnique({ where: { id }, select: { userId: true, event: EV, registration: { select: { event: EV } } } });
|
||||
return shape(p?.event || p?.registration?.event, [p?.userId]);
|
||||
},
|
||||
eventOption: async id => shape((await prisma.eventOption.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
optionVariant: async id => shape((await prisma.optionVariant.findUnique({ where: { id }, select: { eventOption: { select: { event: EV } } } }))?.eventOption?.event),
|
||||
bundle: async id => shape((await prisma.eventBundle.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
allocationSet: async id => shape((await prisma.allocationSet.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
allocationGroup: async id => shape((await prisma.allocationGroup.findUnique({ where: { id }, select: { set: { select: { event: EV } } } }))?.set?.event),
|
||||
waitlistEntry: async id => shape((await prisma.waitlistEntry.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
scanLock: async id => shape((await prisma.scanLock.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
syncReport: async id => shape((await prisma.offlineSyncReport.findUnique({ where: { id }, select: { scanLock: { select: { event: EV } } } }))?.scanLock?.event),
|
||||
section: async id => shape((await prisma.section.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
certificate: async id => shape((await prisma.certificate.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
consentRequest: async id => {
|
||||
const c = await prisma.consentRequest.findUnique({ where: { id }, select: { event: EV, attendee: { select: { registration: { select: { userId: true } } } } } });
|
||||
return shape(c?.event, [c?.attendee?.registration?.userId]);
|
||||
},
|
||||
};
|
||||
|
||||
/** The event a record belongs to (see RESOLVERS), or null. */
|
||||
async function eventOf(kind, id) {
|
||||
const resolve = RESOLVERS[kind];
|
||||
if (!resolve) throw new Error(`No campus resolver for "${kind}"`);
|
||||
if (!id) return null;
|
||||
return resolve(String(id));
|
||||
}
|
||||
|
||||
const NOT_FOUND = { message: 'Not found' };
|
||||
const CHURCH_WIDE_MSG = 'This is an event for all campuses — only admins and supervisors for every campus can change it.';
|
||||
|
||||
/**
|
||||
* Check a scoped user against one event, found from the request. Out of scope → 404 (another
|
||||
* campus's records don't leak); a church-wide event when `need: 'manage'` → 403. Records that
|
||||
* don't exist pass through so the controller answers as it always has.
|
||||
* resolve which RESOLVERS entry turns the id into its event (default 'event')
|
||||
* from req → id (default req.params.eventId || req.params.id)
|
||||
* need 'view' (default) or 'manage'
|
||||
* ownerOk let the record's own user through (a staff member's own registration for another
|
||||
* campus's event) — for routes open to the owner as well as to staff
|
||||
* nameCampus answer 403 naming the event's campus instead of 404 (the ticket scanner: the
|
||||
* person at the door needs to know where the ticket belongs)
|
||||
*/
|
||||
function scopeEvent({ resolve = 'event', from = req => req.params.eventId || req.params.id, need = 'view', ownerOk = false, nameCampus = false } = {}) {
|
||||
if (!RESOLVERS[resolve]) throw new Error(`No campus resolver for "${resolve}"`);
|
||||
const mw = async (req, res, next) => {
|
||||
try {
|
||||
const scope = await scopeFor(req);
|
||||
if (!scope) return next();
|
||||
const ev = await eventOf(resolve, from(req));
|
||||
if (!ev) return next();
|
||||
if (ownerOk && ev.owners.includes(req.user.id)) return next();
|
||||
if (!canView(scope, ev.campusId)) {
|
||||
if (nameCampus) return res.status(403).json({ code: 'OTHER_CAMPUS', message: `This ticket is for an event at ${ev.campusName || 'another campus'}.` });
|
||||
return res.status(404).json(NOT_FOUND);
|
||||
}
|
||||
if (need === 'manage' && !canManage(scope, ev.campusId)) return res.status(403).json({ code: 'CHURCH_WIDE_EVENT', message: CHURCH_WIDE_MSG });
|
||||
req.scopedEvent = ev;
|
||||
return next();
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
};
|
||||
mw.campusScope = `event:${resolve}:${need}${ownerOk ? ':owner-ok' : ''}`;
|
||||
return mw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like scopeEvent, for records named in the request body: `targets` is [[field, resolver], …]
|
||||
* (e.g. [['registrationId', 'registration'], ['eventId', 'event']]). Every target that's given
|
||||
* is checked. With `orphan: 'deny'`, a scoped user is refused when none of them leads to an
|
||||
* event (a donation not linked to any event is for admins and unscoped supervisors only).
|
||||
*/
|
||||
function scopeBody(targets, { need = 'view', orphan = 'pass' } = {}) {
|
||||
for (const [, resolve] of targets) if (!RESOLVERS[resolve]) throw new Error(`No campus resolver for "${resolve}"`);
|
||||
const mw = async (req, res, next) => {
|
||||
try {
|
||||
const scope = await scopeFor(req);
|
||||
if (!scope) return next();
|
||||
let found = false;
|
||||
for (const [field, resolve] of targets) {
|
||||
const value = req.body?.[field];
|
||||
// A list (e.g. ticketIds) checks every id in it.
|
||||
for (const id of (Array.isArray(value) ? value : [value]).filter(Boolean).slice(0, 500)) {
|
||||
const ev = await eventOf(resolve, id);
|
||||
if (!ev) continue;
|
||||
found = true;
|
||||
if (!canView(scope, ev.campusId)) return res.status(404).json(NOT_FOUND);
|
||||
if (need === 'manage' && !canManage(scope, ev.campusId)) return res.status(403).json({ code: 'CHURCH_WIDE_EVENT', message: CHURCH_WIDE_MSG });
|
||||
}
|
||||
}
|
||||
if (!found && orphan === 'deny') {
|
||||
return res.status(403).json({ code: 'CAMPUS_SCOPED', message: "Only admins and supervisors for every campus can work with payments that aren't for an event." });
|
||||
}
|
||||
return next();
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
};
|
||||
mw.campusScope = `body:${targets.map(t => t.join('→')).join(',')}:${need}:${orphan}`;
|
||||
return mw;
|
||||
}
|
||||
|
||||
/** Marker: the controller filters what it lists with listWhere(). */
|
||||
const scopeList = (req, res, next) => next();
|
||||
scopeList.campusScope = 'list';
|
||||
|
||||
/** Admins and unscoped supervisors/staff only. */
|
||||
const unscopedOnly = async (req, res, next) => {
|
||||
try {
|
||||
if (await scopeFor(req)) return res.status(403).json({ code: 'CAMPUS_SCOPED', message: 'Only admins and supervisors for every campus can do this.' });
|
||||
return next();
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
};
|
||||
unscopedOnly.campusScope = 'unscoped-only';
|
||||
|
||||
/** Marker for a route that has nothing to do with any event's campus; the reason is required. */
|
||||
function campusIndependent(reason) {
|
||||
if (!reason) throw new Error('campusIndependent needs a reason');
|
||||
const mw = (req, res, next) => next();
|
||||
mw.campusScope = `independent: ${reason}`;
|
||||
return mw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine a controller's `where` with a scope `where` without either overwriting the other's
|
||||
* OR/AND (spreading would): { AND: [where, extra] }, or `where` itself when there's no scope.
|
||||
*/
|
||||
function withScope(where, extra) {
|
||||
if (!extra || !Object.keys(extra).length) return where;
|
||||
if (!where || !Object.keys(where).length) return extra;
|
||||
return { AND: [where, extra] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts (email/WhatsApp, send/preview/schedule): a scoped supervisor may only message
|
||||
* people at their campuses (home campus) or attendees of events they can see, and can't add
|
||||
* free-typed addresses (`freeformField`: 'emails' or 'phones'). The placeholder event must be
|
||||
* one they can see.
|
||||
*/
|
||||
function scopeBroadcast({ freeformField }) {
|
||||
const mw = async (req, res, next) => {
|
||||
try {
|
||||
const scope = await scopeFor(req);
|
||||
if (!scope) return next();
|
||||
const body = req.body || {};
|
||||
if (String(body[freeformField] || '').trim()) {
|
||||
return res.status(403).json({ code: 'CAMPUS_SCOPED', message: 'Supervisors for some campuses can only message people from the list, not typed-in addresses.' });
|
||||
}
|
||||
if (body.eventId) {
|
||||
const ev = await eventOf('event', body.eventId);
|
||||
if (ev && !canView(scope, ev.campusId)) return res.status(404).json(NOT_FOUND);
|
||||
}
|
||||
const ids = Array.isArray(body.userIds) ? [...new Set(body.userIds.filter(x => typeof x === 'string' && x))] : [];
|
||||
if (ids.length) {
|
||||
const allowed = await prisma.user.count({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
OR: [
|
||||
{ homeCampusId: { in: scope.campusIds } },
|
||||
{ registrations: { some: { event: eventWhere(scope) } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
if (allowed !== ids.length) {
|
||||
const n = ids.length - allowed;
|
||||
return res.status(403).json({ code: 'CAMPUS_SCOPED', message: `${n} of the people selected ${n === 1 ? "isn't" : "aren't"} at your campuses or registered for your events.` });
|
||||
}
|
||||
}
|
||||
return next();
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
};
|
||||
mw.campusScope = 'broadcast';
|
||||
return mw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coupons: a scoped supervisor may see and change a coupon only when it's limited to events,
|
||||
* all at their campuses (a coupon for every event, or a church-wide event, is unscoped-only).
|
||||
* `campusIds` are the campuses of the coupon's events.
|
||||
*/
|
||||
function couponAllowed(scope, campusIds) {
|
||||
if (!scope) return true;
|
||||
return campusIds.length > 0 && campusIds.every(id => id != null && scope.campusIds.includes(id));
|
||||
}
|
||||
|
||||
/** The `where` for a list endpoint: relWhere through `path` ('' = the event itself). */
|
||||
async function listWhere(req, path = '') {
|
||||
const scope = await scopeFor(req);
|
||||
return path ? relWhere(scope, path) : eventWhere(scope);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
scopeFor, eventWhere, manageWhere, relWhere, canView, canManage, eventOf, RESOLVERS,
|
||||
scopeEvent, scopeBody, scopeList, unscopedOnly, campusIndependent, scopeBroadcast, couponAllowed, listWhere, withScope, CHURCH_WIDE_MSG,
|
||||
};
|
||||
@@ -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,124 @@
|
||||
const cs = require('../src/utils/campusScope');
|
||||
|
||||
const north = { campusIds: ['north'], names: ['North'] };
|
||||
|
||||
describe('campus scope rules', () => {
|
||||
test('unscoped sees and manages everything', () => {
|
||||
expect(cs.canView(null, 'south')).toBe(true);
|
||||
expect(cs.canManage(null, null)).toBe(true);
|
||||
expect(cs.eventWhere(null)).toEqual({});
|
||||
});
|
||||
|
||||
test('scoped: own campus and church-wide events are visible, other campuses are not', () => {
|
||||
expect(cs.canView(north, 'north')).toBe(true);
|
||||
expect(cs.canView(north, null)).toBe(true);
|
||||
expect(cs.canView(north, 'south')).toBe(false);
|
||||
});
|
||||
|
||||
test('scoped: only own campus events can be managed — not church-wide ones', () => {
|
||||
expect(cs.canManage(north, 'north')).toBe(true);
|
||||
expect(cs.canManage(north, null)).toBe(false);
|
||||
expect(cs.canManage(north, 'south')).toBe(false);
|
||||
});
|
||||
|
||||
test('list filters, also through relations', () => {
|
||||
const ev = { OR: [{ campusId: { in: ['north'] } }, { campusId: null }] };
|
||||
expect(cs.eventWhere(north)).toEqual(ev);
|
||||
expect(cs.manageWhere(north)).toEqual({ campusId: { in: ['north'] } });
|
||||
expect(cs.relWhere(north, 'registration.event')).toEqual({ registration: { event: ev } });
|
||||
expect(cs.relWhere(null, 'event')).toEqual({});
|
||||
});
|
||||
|
||||
test('withScope never overwrites the other filter', () => {
|
||||
const own = { OR: [{ a: 1 }, { b: 2 }] };
|
||||
const scope = cs.eventWhere(north);
|
||||
expect(cs.withScope(own, scope)).toEqual({ AND: [own, scope] });
|
||||
expect(cs.withScope(own, {})).toBe(own);
|
||||
expect(cs.withScope({}, scope)).toBe(scope);
|
||||
});
|
||||
|
||||
test('coupons: only when limited to events, all at own campuses', () => {
|
||||
expect(cs.couponAllowed(null, [])).toBe(true);
|
||||
expect(cs.couponAllowed(north, [])).toBe(false); // every event
|
||||
expect(cs.couponAllowed(north, ['north'])).toBe(true);
|
||||
expect(cs.couponAllowed(north, ['north', null])).toBe(false); // a church-wide event
|
||||
expect(cs.couponAllowed(north, ['north', 'south'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('campus scope middleware', () => {
|
||||
// The scope is preset on the request (scopeFor caches it there) and the resolvers are stubbed,
|
||||
// so these run without a database.
|
||||
const saved = { ...cs.RESOLVERS };
|
||||
const events = {
|
||||
n1: { id: 'n1', campusId: 'north', campusName: 'North', owners: ['u-owner'] },
|
||||
s1: { id: 's1', campusId: 'south', campusName: 'South', owners: ['u-owner'] },
|
||||
all: { id: 'all', campusId: null, campusName: null, owners: [] },
|
||||
};
|
||||
beforeAll(() => {
|
||||
for (const k of ['event', 'registration', 'ticketQr', 'payment']) cs.RESOLVERS[k] = async id => events[id] || null;
|
||||
});
|
||||
afterAll(() => Object.assign(cs.RESOLVERS, saved));
|
||||
|
||||
const run = async (mw, { scope = north, params = {}, body = {}, userId = 'u-staff' } = {}) => {
|
||||
const req = { _campusScope: scope, params, body, user: { id: userId, role: 'supervisor' } };
|
||||
const out = { status: 200, body: null, next: false };
|
||||
const res = { status(c) { out.status = c; return this; }, json(b) { out.body = b; return this; } };
|
||||
await mw(req, res, () => { out.next = true; });
|
||||
return out;
|
||||
};
|
||||
|
||||
test('scopeEvent: own campus and church-wide pass, another campus is "not found"', async () => {
|
||||
const mw = cs.scopeEvent();
|
||||
expect((await run(mw, { params: { id: 'n1' } })).next).toBe(true);
|
||||
expect((await run(mw, { params: { id: 'all' } })).next).toBe(true);
|
||||
const other = await run(mw, { params: { id: 's1' } });
|
||||
expect(other.next).toBe(false);
|
||||
expect(other.status).toBe(404);
|
||||
});
|
||||
|
||||
test('scopeEvent: unscoped always passes; unknown records pass to the controller', async () => {
|
||||
expect((await run(cs.scopeEvent(), { scope: null, params: { id: 's1' } })).next).toBe(true);
|
||||
expect((await run(cs.scopeEvent(), { params: { id: 'nope' } })).next).toBe(true);
|
||||
});
|
||||
|
||||
test("scopeEvent need 'manage': church-wide events are refused with a reason", async () => {
|
||||
const mw = cs.scopeEvent({ need: 'manage' });
|
||||
expect((await run(mw, { params: { id: 'n1' } })).next).toBe(true);
|
||||
const all = await run(mw, { params: { id: 'all' } });
|
||||
expect(all.status).toBe(403);
|
||||
expect(all.body.code).toBe('CHURCH_WIDE_EVENT');
|
||||
});
|
||||
|
||||
test('scopeEvent ownerOk: the owner of another campus\'s record still gets through', async () => {
|
||||
const mw = cs.scopeEvent({ resolve: 'registration', ownerOk: true });
|
||||
expect((await run(mw, { params: { id: 's1' }, userId: 'u-owner' })).next).toBe(true);
|
||||
expect((await run(mw, { params: { id: 's1' } })).status).toBe(404);
|
||||
});
|
||||
|
||||
test('the scanner names the campus of another campus\'s ticket', async () => {
|
||||
const mw = cs.scopeEvent({ resolve: 'ticketQr', from: r => r.params.qrCode, nameCampus: true });
|
||||
const out = await run(mw, { params: { qrCode: 's1' } });
|
||||
expect(out.status).toBe(403);
|
||||
expect(out.body).toEqual({ code: 'OTHER_CAMPUS', message: 'This ticket is for an event at South.' });
|
||||
});
|
||||
|
||||
test('scopeBody: every target and every id in a list is checked', async () => {
|
||||
const mw = cs.scopeBody([['registrationId', 'registration'], ['ticketIds', 'ticket']]);
|
||||
cs.RESOLVERS.ticket = async id => events[id] || null;
|
||||
expect((await run(mw, { body: { ticketIds: ['n1', 'all'] } })).next).toBe(true);
|
||||
expect((await run(mw, { body: { ticketIds: ['n1', 's1'] } })).status).toBe(404);
|
||||
});
|
||||
|
||||
test("scopeBody orphan 'deny': a payment not linked to any event is unscoped-only", async () => {
|
||||
const mw = cs.scopeBody([['registrationId', 'registration'], ['eventId', 'event']], { orphan: 'deny' });
|
||||
expect((await run(mw, { body: {} })).status).toBe(403);
|
||||
expect((await run(mw, { body: { eventId: 'n1' } })).next).toBe(true);
|
||||
expect((await run(mw, { scope: null, body: {} })).next).toBe(true);
|
||||
});
|
||||
|
||||
test('unscopedOnly refuses scoped users', async () => {
|
||||
expect((await run(cs.unscopedOnly)).status).toBe(403);
|
||||
expect((await run(cs.unscopedOnly, { scope: null })).next).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
// Every staff/supervisor route must say how it handles campus-scoped staff (utils/campusScope.js):
|
||||
// scopeEvent(...), scopeList, unscopedOnly or campusIndependent('reason'). A route that says
|
||||
// nothing would show a campus-limited supervisor every campus's data, so this fails on it.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { staff, supervisor } = require('../src/middleware/authMiddleware');
|
||||
|
||||
const ROUTES_DIR = path.join(__dirname, '..', 'src', 'routes');
|
||||
|
||||
function staffRoutes() {
|
||||
const out = [];
|
||||
for (const file of fs.readdirSync(ROUTES_DIR).filter(f => f.endsWith('.js'))) {
|
||||
const router = require(path.join(ROUTES_DIR, file));
|
||||
for (const layer of router.stack || []) {
|
||||
if (!layer.route) continue;
|
||||
for (const method of Object.keys(layer.route.methods)) {
|
||||
// router.route('/x').get(...).put(...) puts every method's handlers on one route, so the
|
||||
// role is checked per method (admin-only methods are never campus-scoped).
|
||||
const forMethod = layer.route.stack.filter(s => s.method === method || s.method === undefined).map(s => s.handle);
|
||||
if (!forMethod.includes(staff) && !forMethod.includes(supervisor)) continue;
|
||||
const marker = forMethod.find(h => h && h.campusScope);
|
||||
out.push({ file, method: method.toUpperCase(), path: layer.route.path, marker: marker ? marker.campusScope : null });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('campus scope on staff and supervisor routes', () => {
|
||||
const routes = staffRoutes();
|
||||
|
||||
test('finds the staff and supervisor routes', () => {
|
||||
expect(routes.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
test('every one says how it handles campus-scoped staff', () => {
|
||||
const missing = routes.filter(r => !r.marker).map(r => `${r.file} ${r.method} ${r.path}`);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
test('a campus-independent route gives a reason', () => {
|
||||
for (const r of routes.filter(x => x.marker && x.marker.startsWith('independent'))) {
|
||||
expect(r.marker.length).toBeGreaterThan('independent: '.length + 5);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -124,6 +124,18 @@ describe('pasting the Hub public key', () => {
|
||||
expect(normalisePem('')).toBeNull();
|
||||
expect(normalisePem(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
test('without HUB_PUBLIC_KEY the built-in production key is used', () => {
|
||||
const { hubPublicKey, PRODUCTION_PUBLIC_KEY } = require('../src/hub/publicKey');
|
||||
const saved = process.env.HUB_PUBLIC_KEY;
|
||||
delete process.env.HUB_PUBLIC_KEY;
|
||||
try {
|
||||
expect(PRODUCTION_PUBLIC_KEY).toBeTruthy();
|
||||
expect(crypto.createPublicKey(hubPublicKey()).asymmetricKeyType).toBe('ed25519');
|
||||
} finally {
|
||||
if (saved !== undefined) process.env.HUB_PUBLIC_KEY = saved;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('version comparison for "update available"', () => {
|
||||
|
||||
+70
-16
@@ -1,13 +1,24 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-27, after **1.41.2** (and Hub 1.0.0). The plan itself is in
|
||||
Last updated: 2026-10-27, after **1.44.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).
|
||||
|
||||
The product is called **Cross Events** (renamed from Hope Events in 1.41.3); the Hub is the
|
||||
**Cross Events Hub**. Technical names keep `hope-events` on purpose — the repo, browser storage
|
||||
keys (`hope_events_*`), the settings encryption salt, applied migrations, PM2/nginx names, the
|
||||
install folder and the database name — because renaming them would sign everyone out, make
|
||||
encrypted settings unreadable or break existing installs.
|
||||
|
||||
## Done so far (road to 2.0)
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 1.44.0 | **Campus-scoped staff** (§12 part 2): Admin → Users "Limit to campuses"; `utils/campusScope.js` on every staff/supervisor route (and owner-or-staff routes), lists filtered, scanner names the other campus, church-wide events view/scan/door-only for scoped staff; route test `tests/campusScopeRoutes.test.js` |
|
||||
| 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) |
|
||||
| 1.41.2 | Background jobs page: last-run times were 2 h ahead (Prisma's pg adapter drops the offset of timestamptz in raw queries — read pg-boss times `AT TIME ZONE 'UTC'`); `APP_TIMEZONE` documented |
|
||||
| 1.41.1 | Licence fixes — no licence (never connected, or the Hub answers `INSTALL_NOT_FOUND`, which now drops the old licence) is read-only like suspended; read-only also blocks editing events/options/variants; heartbeat 30 s after start and after connecting; `LICENCE_CHECK_DISABLED` for e2e |
|
||||
| 1.41.0 | Cross Events Hub connection — licence step in `/setup` and Site settings → Licence, 15-minute heartbeat (health, totals, income bands), licence states checked offline (grace banner for site admins only, read-only when suspended/revoked), `/health`, "update available", `scripts/install.sh`. The Hub itself is `joshua/events-hub` 1.0.0 |
|
||||
@@ -28,13 +39,12 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
## Next up
|
||||
|
||||
1. **Go live with the Hub:** generate the production signing key on the Hub server
|
||||
(`npm run generate-keys` in `events-hub/backend`), put the **public** key in
|
||||
`backend/src/hub/publicKey.js` and release it as **1.41.1** — until then installs can't
|
||||
connect (they work as "not connected"). Deploy the Hub to `events-hub.crosscode.co.za`, create
|
||||
the admin (`npm run create-admin`), generate Hope Family Church's licence and connect it (install #1).
|
||||
Try `scripts/install.sh` on a test server.
|
||||
2. **1.42.0 — Multi-campus** (§12).
|
||||
1. **Roll out the update requests:** deploy **Hub 1.1.0**, then set "Latest Cross Events release"
|
||||
to `1.42.0` in Hub → Settings, then update the sites to 1.42.0 (the last update done without a
|
||||
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.45.0 — Kids check-in** (§4), then 1.46.0 volunteer rosters (§5) — both per campus.
|
||||
|
||||
## Working on this repo
|
||||
|
||||
@@ -158,24 +168,68 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
(`prepare-db.js` → `retention`); `privacy-notice.spec.ts` publishes a version that needs accepting, then accepts it for all
|
||||
seeded users in `afterAll` so later spec files don't meet the prompt.
|
||||
|
||||
## Where the Hub client lives (1.41.0)
|
||||
## Where the Hub client lives (1.41.0, update requests 1.42.0)
|
||||
|
||||
- Protocol: `docs/hub-protocol.md` — the same file is in the Hub repo (`joshua/events-hub`,
|
||||
developed at `../events-hub`); change both together.
|
||||
- `backend/src/hub/`: `publicKey.js` (the production Hub key — **must be set before releasing**;
|
||||
`HUB_PUBLIC_KEY` env overrides it for a local Hub), `licence.js` (verify tokens, work out
|
||||
active / grace / suspended / revoked / unlicensed, cached 30 s), `client.js` (connect, heartbeat;
|
||||
AppSetting `hub_*` keys, which the generic settings endpoints never touch), `report.js` (health,
|
||||
- `backend/src/hub/`: `publicKey.js` (the production Hub key, built in since 1.42.1 — if the Hub's
|
||||
signing key is ever regenerated, update it here in a release; `HUB_PUBLIC_KEY` env overrides it
|
||||
for a local Hub), `licence.js` (verify tokens, work out
|
||||
active / grace / suspended / revoked / unlicensed, cached 30 s), `client.js` (connect, heartbeat,
|
||||
update request — the last two share `signedPost`; AppSetting `hub_*` keys, which the generic
|
||||
settings endpoints never touch), `report.js` (health,
|
||||
totals, income bands), `readOnly.js` (the routes blocked in read-only mode — add any new
|
||||
"create" or event-edit route there). Read-only = suspended, revoked or unlicensed.
|
||||
- Job `hub-heartbeat` (every 15 min), routes `/api/hub/*`, `/health`.
|
||||
- Frontend: `components/admin/LicenceTab.tsx` (Site settings → Licence; its `ConnectForm` is also on
|
||||
the last `/setup` screen), `components/shared/LicenceBanner.tsx` (site admins only).
|
||||
- Job `hub-heartbeat` (every 15 min), routes `/api/hub/*` (status, connect, heartbeat,
|
||||
update-request), `/health`.
|
||||
- Frontend: `components/admin/LicenceTab.tsx` (Site settings → Licence, with the **Request update**
|
||||
button; its `ConnectForm` is also on the last `/setup` screen), `components/shared/LicenceBanner.tsx`
|
||||
(site admins only).
|
||||
- **Updates:** sites never update themselves and never show the deploy command. "Request update"
|
||||
tells the Hub (an `update_requested` alert + one email to Joshua); the Hub resolves it when the
|
||||
site's heartbeat reports a newer version. Joshua then runs `scripts/deploy.sh` on the server.
|
||||
- Local testing: run the Hub (`../events-hub`, ports 5200/3200), create a licence there, and start
|
||||
Cross Events with `HUB_URL=http://localhost:5200` and `HUB_PUBLIC_KEY` = the key on Hub → Settings.
|
||||
- `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.
|
||||
|
||||
## Campus-scoped staff (1.44.0) — adding a staff route
|
||||
|
||||
- **Every route behind `staff` or `supervisor` must carry a marker from `utils/campusScope.js`**, or
|
||||
`tests/campusScopeRoutes.test.js` fails:
|
||||
- `scopeEvent({ resolve, from, need, ownerOk, nameCampus })` — one event, or a record traced to
|
||||
one (`RESOLVERS`: registration, ticket, ticketQr, payment, eventOption, optionVariant, bundle,
|
||||
allocationSet/Group, waitlistEntry, scanLock, syncReport, section, certificate, consentRequest).
|
||||
`need: 'manage'` for setup, cashup and refunds (church-wide events refused).
|
||||
- `scopeBody([[field, resolver], …], { need, orphan })` — records named in the body (lists of ids
|
||||
too); `orphan: 'deny'` when there must be an event (payments).
|
||||
- `scopeList` — the controller filters with `listWhere(req, path)` / `withScope(where, extra)`
|
||||
(never spread a scope `where` over one that has its own OR) or checks itself.
|
||||
- `unscopedOnly`, `campusIndependent('reason')`, `scopeBroadcast({ freeformField })`.
|
||||
- Routes open to the owner *and* staff (no role middleware, so the test can't see them) need
|
||||
`scopeEvent({ …, ownerOk: true })` by hand — registrations, payments, tickets, consent requests
|
||||
and coupon validation have it.
|
||||
- Rules: admins never scoped; scopes ignored while campuses are off; another campus → 404, except
|
||||
the scanner (403 naming the campus). Tested in `tests/campusScope.test.js` and e2e
|
||||
`campus-scoped.spec.ts` (staff and supervisor scoped to E2E South; a seeded North ticket).
|
||||
|
||||
## Known gaps / follow-ups
|
||||
|
||||
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
|
||||
|
||||
+98
-31
@@ -11,7 +11,7 @@ sold — trustworthy (POPIA), flexible (multi-day events, several payment
|
||||
providers), maintainable (tests, CI, typed backend, versioned API), and with
|
||||
**AI helpers** as the headline feature (§15).
|
||||
|
||||
**Status (2026-10-23):** all features in this document are approved. Shipped
|
||||
**Status (2026-10-27):** all features in this document are approved. Shipped
|
||||
so far: 1.32.0 early-bird ribbon on event cards; 1.33.0 multi-day events
|
||||
phase A (sessions, late joining, per-session check-in, attendance register,
|
||||
reminders, certificates); 1.34.0 job queue (pg-boss), Playwright E2E tests, Gitea CI
|
||||
@@ -22,8 +22,15 @@ picker at the door and session choice on "All sessions" tickets; 1.36.0 payment
|
||||
1.37.1 "Pay online" hidden when it can't be used; 1.37.2 payment status checks for missing
|
||||
notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (2026-10-23);
|
||||
1.38.0 POPIA toolkit part 1 (privacy page, unsubscribe, WhatsApp STOP, data requests); 1.39.0 part 2 (privacy notice editor and versions, operator register, consent and access logs); 1.40.0 part 3 (retention policies and job, breach register, children’s-data flags, readiness page).
|
||||
1.41.0 the Hub connection (licence, heartbeat, read-only mode, `/health`, install script) with Hub 1.0.0 (`joshua/events-hub`).
|
||||
**Next: 1.42.0 Multi-campus.**
|
||||
1.41.0 the Hub connection (licence, heartbeat, read-only mode, `/health`, install script) with Hub 1.0.0 (`joshua/events-hub`);
|
||||
1.41.1–1.41.2 licence and job-time fixes; 1.41.3 renamed **Hope Events → Cross Events** (Hub 1.0.1:
|
||||
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). 1.43.0 **campuses** shipped (settings, events
|
||||
per campus, public filter and campus pages, home campus, filters, campus broadcasts and summaries);
|
||||
1.44.0 **campus-scoped staff** shipped (Admin → Users "Limit to campuses", every staff route checked).
|
||||
**Next: 1.45.0 Kids check-in.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -517,7 +524,12 @@ full data separation — which also helps the POPIA story — and lets each
|
||||
church run on a different server. **True multi-tenant is out of scope.**
|
||||
|
||||
**Multi-campus** is in scope: one church with several campuses inside one
|
||||
install.
|
||||
install. It ships in **two releases** (2026-10-27):
|
||||
|
||||
| Release | What | Why separate |
|
||||
|---------|------|--------------|
|
||||
| **1.43.0 Campuses** | Campuses in Site settings, events per campus, public filter and `/campus/<slug>` pages, home campus, campus on cards and tickets, campus filters on reports/cashup/dashboard/funnel, "everyone at campus X" messages, per-campus summary recipients | Adds features and filters; who can see what doesn't change |
|
||||
| **1.44.0 Campus-scoped staff** | Supervisors and staff limited to campuses, across every staff/supervisor endpoint (115 in 27 route files at 1.42.1) and their dashboards | Security-heavy: anything missed lets staff see another campus's data, so it gets its own release and testing |
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -531,17 +543,22 @@ install.
|
||||
- **Events** belong to one campus, or to **All campuses** (church-wide
|
||||
conferences, camps).
|
||||
- **Public site:** campus filter on the events list, remembered per visitor;
|
||||
campus shown on event cards and tickets. Optional campus landing pages
|
||||
(`/campus/<slug>`).
|
||||
campus shown on event cards and tickets. Campus landing pages
|
||||
(`/campus/<slug>`) are part of 1.43.0.
|
||||
- **Users** can set a home campus (used as the default filter and for
|
||||
broadcasts). Accounts stay shared across campuses — one login for the
|
||||
church.
|
||||
- **Campus-scoped roles:** a supervisor or staff member can be limited to
|
||||
- **Campus-scoped roles (1.44.0):** a supervisor or staff member can be limited to
|
||||
one or more campuses (sees and manages only those events, registrations,
|
||||
scanning, cashups). Admins and unscoped supervisors see everything.
|
||||
**"All campuses" events:** scoped staff can view and scan them; only admins
|
||||
and unscoped supervisors can edit them.
|
||||
- **Reports, cashup, dashboard, funnel stats:** filter by campus plus a
|
||||
church-wide total.
|
||||
- **Broadcasts:** target "everyone at campus X".
|
||||
church-wide total. (Report PDFs/Excel are rendered from data the page sends,
|
||||
so the filters go on the data endpoints.)
|
||||
- **Broadcasts:** target "everyone at campus X" — people whose home campus is X,
|
||||
plus (tick box) people registered for campus X's events in the last 12 months.
|
||||
Still filtered by news consent.
|
||||
- **Kids check-in rooms** and **volunteer roles** are per campus.
|
||||
- **Notification recipients** per campus (e.g. the campus pastor gets that
|
||||
campus's daily summary).
|
||||
@@ -553,21 +570,64 @@ install.
|
||||
- Moving an event to another campus → scoped supervisors of the old campus
|
||||
lose access; logged in the audit log.
|
||||
|
||||
### Plumbing (1.44.0)
|
||||
|
||||
- Today access depends only on role — nothing limits staff to particular
|
||||
events — so scoping is a new layer: one helper (`utils/campusScope.js`) that
|
||||
works out a user's campuses once per request, gives a `where` for event lists
|
||||
and checks single records (registrations, tickets, payments… traced back to
|
||||
their event).
|
||||
- A test lists **every** staff/supervisor route and fails unless each is
|
||||
campus-checked or explicitly marked campus-independent (the same pattern as
|
||||
the read-only route test), so new endpoints can't skip the check.
|
||||
- The heartbeat's `features.campuses` / `stats.campuses` report the real setting
|
||||
(hard-coded `false` until 1.43.0).
|
||||
- **How each kind of endpoint is checked:** event id in the URL → a route middleware
|
||||
(`scopeEvent`); a record that belongs to an event (registration, ticket/QR, waitlist
|
||||
entry, option, bundle, allocation set…) → traced back to its event, same check;
|
||||
lists across events → the controller adds the scope `where` (route marked so the
|
||||
test knows); an event named in the body → checked before saving; nothing to do with
|
||||
an event → allowed or unscoped-only, listed with a reason.
|
||||
- **Rules (decided 2026-10-27):**
|
||||
- Admins are never scoped. Scoped supervisors and staff see their campuses' events
|
||||
plus "All campuses" events.
|
||||
- "All campuses" events: scoped staff can view, scan, take payments at the door and
|
||||
do manual registrations; event setup, options, bundles, cashup and refunds stay with
|
||||
admins and unscoped supervisors.
|
||||
- Site banner, payment-provider reconciliation and donations not linked to an event:
|
||||
admins and unscoped supervisors only. Ad-hoc broadcasts: scoped supervisors only to
|
||||
their campuses' audiences and their events' attendees. Coupons: only for their
|
||||
campuses' events.
|
||||
- Another campus's records answer "not found" (nothing leaks); the scanner instead
|
||||
says which campus the ticket is for.
|
||||
- While campuses are off, scopes are ignored (everyone sees everything); the Campuses
|
||||
tab warns which staff are limited before turning campuses off.
|
||||
- Scopes are set by admins in Admin → Users ("Limit to campuses"); changes are audited
|
||||
(`user_campus_scope_changed`).
|
||||
|
||||
### Data model (sketch)
|
||||
|
||||
```prisma
|
||||
model Campus {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
address String?
|
||||
isActive Boolean @default(true)
|
||||
order Int @default(0)
|
||||
events Event[]
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
address String?
|
||||
contactName String?
|
||||
contactEmail String?
|
||||
contactPhone String?
|
||||
colour String?
|
||||
logoUrl String?
|
||||
defaultLocation String?
|
||||
order Int @default(0)
|
||||
archivedAt DateTime? // archive instead of delete when it has events
|
||||
events Event[]
|
||||
notifyRecipients User[] // daily summary for this campus's events
|
||||
}
|
||||
// Event.campusId String? (null = all campuses)
|
||||
// User.homeCampusId String?
|
||||
// UserCampusScope { userId, campusId } — empty = unscoped
|
||||
// Event.campusId String? (null = all campuses) — 1.43.0
|
||||
// User.homeCampusId String? — 1.43.0
|
||||
// AppSetting campuses_enabled — 1.43.0
|
||||
// UserCampusScope { userId, campusId } — empty = unscoped — 1.44.0
|
||||
```
|
||||
|
||||
### Running many installs
|
||||
@@ -601,11 +661,11 @@ whether they're healthy, basic usage stats, and their **licence** status.
|
||||
- Same stack (Next.js + Express + Prisma + Postgres) so it's familiar.
|
||||
- **What lives where:**
|
||||
- Hub repo: dashboard, customers, licences, licence **signing** (private
|
||||
key — only ever on the Hub server), `/api/installs/register` and
|
||||
`/api/installs/heartbeat` endpoints, alerts.
|
||||
key — only ever on the Hub server), `/api/installs/register`,
|
||||
`/api/installs/heartbeat` and `/api/installs/update-request` endpoints, alerts.
|
||||
- Cross Events repo: the Hub **client** — licence step in `/setup`,
|
||||
"Connect to Hub" in Site settings, heartbeat job, licence verification
|
||||
with the Hub's **public** key, read-only mode, banners.
|
||||
with the Hub's **public** key, read-only mode, banners, "Request update".
|
||||
- **The contract between them** (register / heartbeat payloads, licence token
|
||||
format) is versioned with a `protocolVersion` field and documented in both
|
||||
repos (`docs/hub-protocol.md`). The Hub accepts the current and previous
|
||||
@@ -661,7 +721,9 @@ All later calls are signed with the install secret (HMAC) and go over HTTPS.
|
||||
- **Alerts** (email/WhatsApp to Joshua): install offline > 1 h, backup
|
||||
failed or older than 48 h, disk low, errors spiking, licence expiring.
|
||||
- **Releases:** latest version and changelog; installs see "Update
|
||||
available" in their admin.
|
||||
available" in their admin with a **Request update** button (1.42.0 / Hub 1.1.0).
|
||||
The request is signed like a heartbeat, shows as an alert (emailed once) and
|
||||
clears when the install reports a newer version. Joshua does the update.
|
||||
- **Customers:** contact details, plan, licence(s), billing notes.
|
||||
|
||||
### Licences
|
||||
@@ -695,7 +757,8 @@ All later calls are signed with the install secret (HMAC) and go over HTTPS.
|
||||
- The Hub should not offer remote commands (run updates, impersonate,
|
||||
database access) in v1: it's the most valuable target an attacker could
|
||||
hit, so keeping it read-only toward installs keeps every church safe if it
|
||||
is ever compromised. Remote "trigger update" can be revisited later.
|
||||
is ever compromised. Updates stay that way: the install *asks* (Request update)
|
||||
and Joshua updates the server; the Hub never runs anything on an install.
|
||||
- The Hub itself needs 2FA, backups and the same audit log approach.
|
||||
|
||||
### Data model (Hub, sketch)
|
||||
@@ -707,7 +770,7 @@ model Install { id, customerId, licenceId, name, url, secretHash, version,
|
||||
lastHeartbeatAt, health Json, registeredAt }
|
||||
model Heartbeat { id, installId, receivedAt, version, health Json, stats Json }
|
||||
// rolled up to daily after 30 days
|
||||
model InstallEvent { id, installId, type, message, createdAt } // alerts, version changes
|
||||
model InstallEvent { id, installId, type, message, createdAt } // alerts, version changes, update requests
|
||||
// 2.0, AI (§15): Licence gets aiMonthlyCapCents; usage per call, no prompt/response text
|
||||
model AiUsage { id, installId, helper, model, inputTokens, outputTokens, costCents, createdAt }
|
||||
```
|
||||
@@ -869,12 +932,12 @@ income justify the hardware:
|
||||
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
|
||||
| 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 ✅ + Hub v1 ✅ | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
|
||||
| 7 | 1.42.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
|
||||
| 8 | 1.43.0 – 1.44.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
|
||||
| 9 | 1.45.0 – 1.47.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
|
||||
| 10 | 1.48.0 – 1.50.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
|
||||
| 11 | 1.51.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 11 | 1.53.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
|
||||
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 API removed, migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
@@ -920,6 +983,10 @@ feature.
|
||||
| — | POPIA: privacy notice | Today's notice becomes the starting template, one editable box per section; org name, Information Officer, retention periods and the operator list fill in automatically. Publishing saves a numbered version (2026-10-23) |
|
||||
| — | POPIA: re-accepting the notice | The admin decides per version ("significant change — ask everyone to accept again"); people are asked once after login (2026-10-23) |
|
||||
| — | POPIA: inactive accounts | Anonymised after 5 years without a login or registration, **on by default**, with a warning email 30 days before; staff/supervisor/admin accounts never (2026-10-23) |
|
||||
| — | Product name | **Cross Events** (was Hope Events), Hub: **Cross Events Hub**. UI text and docs renamed in 1.41.3; technical identifiers (`hope-events` repo, storage keys, encryption salt, migrations, PM2/nginx names) stay, so installs and sign-ins keep working (2026-10-27) |
|
||||
| — | Campus-scoped staff (1.44.0) | Scoped staff: their campuses' events plus "All campuses" events, which they can view, scan, take door payments and do manual registrations for, but not set up, cash up or refund. Banner, provider reconciliation and unlinked donations: unscoped only; broadcasts limited to their campuses' audiences and their events' attendees; coupons only for their events. Other campuses' records answer "not found" except the scanner, which names the campus. Scopes ignored while campuses are off, with a warning (2026-10-27) |
|
||||
| — | Multi-campus split | Two releases: **1.43.0 campuses**, **1.44.0 campus-scoped staff**; everything after moves up by one. Scoped staff can view and scan "All campuses" events, only admins and unscoped supervisors edit them. "Everyone at campus X" = home campus X, plus (tick box) registrants of campus X's events in the last 12 months. Campus landing pages are in 1.43.0 (2026-10-27) |
|
||||
| — | Updates | Sites show **Request update**, never the deploy command: the request goes to the Hub, which tells Joshua; he updates the server. 1.42.0 / Hub 1.1.0; Multi-campus and everything after moves up by one (2026-10-27) |
|
||||
| — | POPIA: WhatsApp STOP | In 1.38.0, via WAWP's incoming `message` webhook with a secret in the URL (WAWP doesn't sign webhooks) (2026-10-23) |
|
||||
|
||||
## Still open
|
||||
|
||||
@@ -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,52 @@
|
||||
import { test, expect, seed, api, loginAs, API } from "./fixtures";
|
||||
|
||||
// Campus-scoped staff (1.44.0): the seeded staff member and supervisor are limited to E2E South
|
||||
// for these tests; afterwards the limits are cleared and campuses turned off again.
|
||||
test.describe("campus-scoped staff", () => {
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const page = await browser.newPage();
|
||||
await api(page, "admin", "PUT", "/api/campuses/settings", { enabled: true });
|
||||
for (const role of ["staff", "supervisor"] as const) {
|
||||
await api(page, "admin", "PUT", `/api/users/${seed().users[role].id}`, { scopedCampusIds: [seed().campuses.south.id] });
|
||||
}
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
const page = await browser.newPage();
|
||||
for (const role of ["staff", "supervisor"] as const) {
|
||||
await api(page, "admin", "PUT", `/api/users/${seed().users[role].id}`, { scopedCampusIds: [] });
|
||||
}
|
||||
await api(page, "admin", "PUT", "/api/campuses/settings", { enabled: false });
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test("the scanner refuses another campus's ticket and says which campus", async ({ page }) => {
|
||||
const scan = (qr: string) => page.request.get(`${API}/api/tickets/scan-preview/${encodeURIComponent(qr)}`, { headers: { Authorization: `Bearer ${seed().users.staff.token}` } });
|
||||
const north = await scan(seed().northTicketQr);
|
||||
expect(north.status()).toBe(403);
|
||||
expect((await north.json()).message).toBe("This ticket is for an event at E2E North.");
|
||||
// The course is for all campuses, so it scans.
|
||||
expect((await scan(seed().course.qrCode)).status()).toBe(200);
|
||||
});
|
||||
|
||||
test("a scoped supervisor sees their campus and church-wide events, and can't edit church-wide ones", async ({ page }) => {
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor");
|
||||
await expect(page.getByText("You work with E2E South, and events for all campuses.")).toBeVisible();
|
||||
|
||||
await page.goto("/dashboard/supervisor/events");
|
||||
await expect(page.getByText("E2E Paid Conference")).toBeVisible(); // South
|
||||
await expect(page.getByText("E2E Alpha Course")).toBeVisible(); // all campuses
|
||||
await expect(page.getByText("E2E Free Evening")).toBeHidden(); // North
|
||||
|
||||
await page.getByText("E2E Alpha Course").click();
|
||||
await expect(page.getByText(/This is an event for all campuses — only admins and supervisors for every campus can change it/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Save Changes" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("another campus's registrations are not found for a scoped supervisor", async ({ page }) => {
|
||||
const res = await page.request.get(`${API}/api/registrations/event/${seed().events.free}`, { headers: { Authorization: `Bearer ${seed().users.supervisor.token}` } });
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -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,8 @@ 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 }>;
|
||||
northTicketQr: 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 }] },
|
||||
@@ -153,9 +160,23 @@ async function seed(url) {
|
||||
const ticket = await prisma.ticket.findFirst({ where: { eventId: course.id } });
|
||||
await prisma.ticketUsage.create({ data: { ticketId: ticket.id, scannedById: users.staff.id, sessionId: course.sessions[0].id, scannedAt: course.sessions[0].startsAt } });
|
||||
|
||||
// A ticket for the free evening (North campus), for the campus-scoped scanner test. The admin
|
||||
// holds it so it never collides with the registration tests (which use Thandi).
|
||||
const freeOption = await prisma.eventOption.findFirst({ where: { eventId: free.id } });
|
||||
const northReg = await prisma.registration.create({
|
||||
data: {
|
||||
userId: users.admin.id, eventId: free.id, status: 'paid',
|
||||
registrationOptions: { create: [{ eventOptionId: freeOption.id, quantity: 1, priceSnapshot: 0, tranches: { create: [{ quantity: 1, priceSnapshot: 0 }] } }] },
|
||||
},
|
||||
});
|
||||
await generateTicketsForRegistration(northReg.id);
|
||||
const northTicket = await prisma.ticket.findFirst({ where: { registrationOption: { registrationId: northReg.id } } });
|
||||
|
||||
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 } },
|
||||
northTicketQr: northTicket.qrCode,
|
||||
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.0",
|
||||
"version": "1.44.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.42.0",
|
||||
"version": "1.44.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.42.0",
|
||||
"version": "1.44.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" mine />
|
||||
</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 { RoleBadge, type Role as RoleBadgeRole } from "@/components/shared/RoleB
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Paginated } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { useCampuses } from "@/hooks/useCampuses";
|
||||
|
||||
interface UserItem {
|
||||
id: string;
|
||||
@@ -142,14 +143,26 @@ export default function AdminUsersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (u: UserItem) => { setEditingId(u.id); setEditData({ ...u, password: "" }); };
|
||||
const cancelEdit = () => { setEditingId(null); setEditData({}); };
|
||||
// Campus-scoped staff: the campuses a supervisor/staff member is limited to (loaded per user;
|
||||
// null until loaded, so an unloaded list is never saved over the real one).
|
||||
const campusInfo = useCampuses();
|
||||
const [scope, setScope] = useState<{ id: string; name: string }[] | null>(null);
|
||||
const startEdit = (u: UserItem) => {
|
||||
setEditingId(u.id); setEditData({ ...u, password: "" }); setScope(null);
|
||||
apiFetch<{ scopedCampuses?: { id: string; name: string }[] }>(`/api/users/${encodeURIComponent(u.id)}`, { authToken: token! })
|
||||
.then(full => setScope(full.scopedCampuses || []))
|
||||
.catch(() => setScope(null));
|
||||
};
|
||||
const cancelEdit = () => { setEditingId(null); setEditData({}); setScope(null); };
|
||||
const canBeScoped = editData.role === "staff" || editData.role === "supervisor";
|
||||
// Active campuses, plus any archived one this person is still limited to.
|
||||
const scopeChoices = [...campusInfo.campuses.map(c => ({ id: c.id, name: c.name })), ...(scope || []).filter(s => !campusInfo.campuses.some(c => c.id === s.id)).map(s => ({ ...s, name: `${s.name} (archived)` }))];
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editingId) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
const payload: Partial<UserItem> & { password?: string } = {
|
||||
const payload: Partial<UserItem> & { password?: string; scopedCampusIds?: string[] } = {
|
||||
name: editData.name,
|
||||
email: editData.email,
|
||||
role: editData.role,
|
||||
@@ -160,6 +173,7 @@ export default function AdminUsersPage() {
|
||||
if (editData.password && editData.password.trim().length > 0) {
|
||||
payload.password = editData.password.trim();
|
||||
}
|
||||
if (campusInfo.enabled && canBeScoped && scope) payload.scopedCampusIds = scope.map(c => c.id);
|
||||
await apiFetch(`/api/users/${encodeURIComponent(editingId)}`, {
|
||||
method: "PUT", authToken: token!, body: payload,
|
||||
});
|
||||
@@ -518,6 +532,32 @@ export default function AdminUsersPage() {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{campusInfo.enabled && canBeScoped && (
|
||||
<fieldset className="border rounded p-3">
|
||||
<legend className="text-xs text-gray-600 px-1">Limit to campuses</legend>
|
||||
{scope === null ? <p className="text-xs text-gray-400">Loading…</p> : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{scopeChoices.map(c => (
|
||||
<label key={c.id} className="flex items-center gap-1.5 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scope.some(s => s.id === c.id)}
|
||||
onChange={e => setScope(prev => e.target.checked ? [...(prev || []), c] : (prev || []).filter(s => s.id !== c.id))}
|
||||
/>
|
||||
{c.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 mt-1">
|
||||
{scope.length === 0
|
||||
? "Not limited — sees every campus."
|
||||
: "Sees only these campuses' events plus events for all campuses. Can't set up, cash up or refund events for all campuses, or use the site banner and payment reconciliation."}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">New password (leave blank to keep current)</label>
|
||||
<input type="password" placeholder="Set new password" className="w-full border rounded px-3 py-2 text-sm" value={editData.password || ""} onChange={e => setEditData(d => ({ ...d, password: e.target.value }))} />
|
||||
|
||||
@@ -10,6 +10,7 @@ import { QrCode, Ticket, Activity, UserCheck, Clock, Users, ClipboardCheck } fro
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
import type { DashboardStats, RecentScan, ScanStats } from "@/types";
|
||||
import { CampusScopeNote } from "@/components/shared/CampusFilter";
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
|
||||
@@ -75,6 +76,7 @@ export default function StaffDashboardPage() {
|
||||
<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 today's scanning activity.</p>
|
||||
<CampusScopeNote className="mt-2" />
|
||||
</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";
|
||||
|
||||
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 { useMyCampuses } 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,10 @@ interface EventModalProps {
|
||||
function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
const { token } = useAuth();
|
||||
const { settings } = useSiteSettings();
|
||||
const campusInfo = useMyCampuses();
|
||||
// Campus-scoped: events for all campuses (and other campuses') are set up by admins and
|
||||
// supervisors for every campus — the server refuses changes, so the editor says so up front.
|
||||
const campusReadOnly = mode === "edit" && campusInfo.scoped && !campusInfo.campuses.some(c => c.id === ev?.campusId);
|
||||
const [step, setStep] = useState<StepIdx>(0);
|
||||
const [pricingSubstep, setPricingSubstep] = useState<PricingSubstep>(0);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -897,10 +905,23 @@ 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 || ""));
|
||||
|
||||
// A campus-scoped person's new event starts at their (first) campus — they can't make
|
||||
// events for all campuses — with that campus's default location if the field is still default.
|
||||
useEffect(() => {
|
||||
if (mode !== "create" || !campusInfo.scoped || !campusInfo.campuses.length) return;
|
||||
setDraft(d => {
|
||||
if (d.campusId) return d;
|
||||
const first = campusInfo.campuses[0];
|
||||
const isDefault = !d.location.trim() || d.location === (settings.org_address || "");
|
||||
return { ...d, campusId: first.id, ...(first.defaultLocation && isDefault ? { location: first.defaultLocation } : {}) };
|
||||
});
|
||||
}, [mode, campusInfo.scoped, campusInfo.campuses, settings.org_address]);
|
||||
|
||||
// ── multi-day schedule + attendance settings ──
|
||||
const [sessions, setSessions] = useState<KeyedSession[]>(() => (ev?.sessions || []).map(s => keyed(toSessionDraft(s))));
|
||||
const [attendance, setAttendance] = useState<AttendanceDraft>(() => ({
|
||||
@@ -1284,6 +1305,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;
|
||||
@@ -1417,6 +1440,11 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
This event is closed — it can't be edited until it's reopened from the cashup page (admin only).
|
||||
</div>
|
||||
)}
|
||||
{campusReadOnly && (
|
||||
<div className="p-3 border rounded bg-amber-50 border-amber-200 text-amber-900 text-sm">
|
||||
{ev?.campusId ? "This event belongs to another campus" : "This is an event for all campuses"} — only admins and supervisors for every campus can change it. You can still work with its registrations, payments and tickets.
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="p-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
|
||||
{changedNotice && (
|
||||
<div className="p-3 border rounded bg-amber-50 border-amber-200 text-amber-900 text-sm space-y-2">
|
||||
@@ -1473,6 +1501,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 } : {}) });
|
||||
}}
|
||||
>
|
||||
{campusInfo.scoped ? <option value="" disabled>Choose your campus…</option> : <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" />
|
||||
@@ -1815,8 +1866,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
<button type="button" onClick={handleNext}
|
||||
className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50">Next →</button>
|
||||
)}
|
||||
<button type="button" disabled={saving || isClosed} onClick={handleSave}
|
||||
title={isClosed ? "This event is closed — reopen it first to make changes" : undefined}
|
||||
<button type="button" disabled={saving || isClosed || campusReadOnly} onClick={handleSave}
|
||||
title={isClosed ? "This event is closed — reopen it first to make changes" : campusReadOnly ? "Only admins and supervisors for every campus can change this event" : undefined}
|
||||
className="px-5 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">
|
||||
{saving ? "Saving…" : "Save Changes"}
|
||||
</button>
|
||||
@@ -1844,6 +1895,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 +1949,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 +1958,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 +1994,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 mine />
|
||||
<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, CampusScopeNote } 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,8 @@ 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" mine />
|
||||
<CampusScopeNote className="mt-2" />
|
||||
</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,261 @@
|
||||
"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; scopedStaff: 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) => {
|
||||
// Limits are ignored while campuses are off, so limited staff would see every campus.
|
||||
const limited = (campuses || []).reduce((n, c) => n + (c._count.scopedStaff || 0), 0);
|
||||
if (!on && limited > 0 && !window.confirm(`Some staff are limited to campuses (${limited} limit${limited === 1 ? "" : "s"} set). While campuses are off those limits are ignored and they'll see every campus. Turn campuses off?`)) return;
|
||||
return 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._count.scopedStaff ? <>{" · "}{c._count.scopedStaff} staff limited to it</> : null}
|
||||
{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,16 @@ 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"
|
||||
mine
|
||||
/>
|
||||
|
||||
<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 { useMyCampuses } 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 } = useMyCampuses();
|
||||
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,74 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { useCampuses, useMyCampuses, 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", mine = false }: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
withChurchWide?: boolean;
|
||||
everyLabel?: string;
|
||||
className?: string;
|
||||
id?: string;
|
||||
// Staff pages: only the campuses a campus-scoped person works with.
|
||||
mine?: boolean;
|
||||
}) {
|
||||
const everyCampus = useCampuses();
|
||||
const myCampuses = useMyCampuses();
|
||||
const { enabled, campuses } = mine ? myCampuses : everyCampus;
|
||||
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>;
|
||||
}
|
||||
|
||||
/** For campus-scoped staff: which campuses they work with. Nothing for everyone else. */
|
||||
export function CampusScopeNote({ className = "" }: { className?: string }) {
|
||||
const { scoped, campuses } = useMyCampuses();
|
||||
if (!scoped) return null;
|
||||
return (
|
||||
<p className={`inline-flex items-center gap-1.5 text-xs text-gray-600 bg-gray-50 border rounded-full px-3 py-1 ${className}`}>
|
||||
<MapPin className="w-3.5 h-3.5" aria-hidden />
|
||||
You work with {campuses.map(c => c.name).join(", ") || "your campuses"}, and events for all campuses.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Users, UserPlus, ShieldAlert, Eye } from "lucide-react";
|
||||
import { Users, UserPlus, ShieldAlert, Eye, MapPinned } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -19,6 +19,9 @@ export const dashboardAdminUsersHelpContent: HelpContent = {
|
||||
<GuideItem icon={UserPlus} title="Editing a user" tone="brand">
|
||||
Click a row to open the edit modal — you can change name, email, role, phone, notification preference, active status, or set a new password.
|
||||
</GuideItem>
|
||||
<GuideItem icon={MapPinned} title="Limit to campuses" tone="violet">
|
||||
With campuses on (Site settings → Campuses), a supervisor or staff member can be limited to some campuses. They then see only those campuses' events plus events for all campuses: they can scan, register people and take payments for church-wide events, but not change them, cash them up or refund them. They also can't use the site banner or payment reconciliation, and only message people at their campuses. Leave every box empty for someone who works with every campus. Admins are never limited, and limits are ignored while campuses are off.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -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,33 @@ 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 — or with staff limited to it — 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, and staff limits are ignored while off; nothing is deleted, and it all comes back when you turn them on again.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Users} title="Staff for some campuses" tone="violet">
|
||||
In Admin → Users, a supervisor or staff member can be limited to some campuses — they then only work with those campuses' events and events for all campuses. See the Users help for what they can and can't do.
|
||||
</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,68 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The campuses the signed-in person works with: for campus-scoped staff only their own campuses
|
||||
* (`scoped` true — they can't set up events for all campuses), for everyone else every campus.
|
||||
* Staff dropdowns use this; the public site uses useCampuses.
|
||||
*/
|
||||
export function useMyCampuses() {
|
||||
const all = useCampuses();
|
||||
const { user } = useAuth();
|
||||
const scope = all.enabled ? user?.campusScope || null : null;
|
||||
return { ...all, scoped: !!scope, campuses: scope ? all.campuses.filter(c => scope.includes(c.id)) : all.campuses };
|
||||
}
|
||||
@@ -13,6 +13,10 @@ 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;
|
||||
// Campus-scoped staff: the campuses they're limited to (null = every campus).
|
||||
campusScope?: 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.0",
|
||||
"version": "1.44.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.42.0",
|
||||
"version": "1.44.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.0",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user