Compare commits

..
22 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 a6909ce506 Campuses frontend, docs and e2e (1.43.0)
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
Site settings -> Campuses tab; campus picker in the event editor; campus
filter and badges on the events list, public events page (remembered,
starts on home campus) and reports/dashboards; /campus/<slug> pages and
sitemap; home campus on the profile; "Everyone at a campus" in email and
WhatsApp broadcasts. Help, FEATURES, SETTINGS, API docs, protocol doc
(campusCount), handover and roadmap updated; campuses seeded for e2e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:45:43 +02:00
joshuaandClaude Opus 5.5 955f8d9831 Campuses backend: model, admin/public API, events per campus, filters
- Campus model, Event.campusId (null = all campuses), User.homeCampusId,
  per-campus summary recipients; campuses_enabled setting (off by default)
- /api/campuses: public list and landing page, admin CRUD/order/archive,
  "everyone at campus X" audience (home campus + optional 12-month registrants)
- Events: campus on create/update (audited when moved), ?campus= on the
  public list (with church-wide events) and staff list; campus included
- Home campus on the profile; cleared when anonymising
- Overview stats ?campus= filter; campus summary recipients; campus on
  ticket PDFs; heartbeat reports the real campus setting
- Migration also adds hub_update_requested, missing since 1.42.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:23:33 +02:00
joshua 2727fc6554 Merge branch 'docs/multi-campus-plan' 2026-10-27 16:09:06 +02:00
joshuaandClaude Opus 5.5 3d149e6098 Roadmap: split multi-campus into 1.43.0 campuses and 1.44.0 scoped staff
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
Records the decisions: scoped staff view and scan "All campuses" events but
can't edit them; "everyone at campus X" is home campus plus optional recent
registrants; campus landing pages in 1.43.0. Later phases move up by one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:09:04 +02:00
joshua 30a2142085 Merge branch 'fix/hub-public-key'
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
2026-10-27 14:28:49 +02:00
joshuaandClaude Opus 5.5 95913a5bd5 Build the production Hub public key in (1.42.1)
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
PRODUCTION_PUBLIC_KEY is the key the production Hub serves at
/api/public-key, normalised to PEM, so installs no longer need
HUB_PUBLIC_KEY. Test covers the built-in key with the env var unset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:28:48 +02:00
joshua f7e0ec8bc1 Merge branch 'docs/handover-roadmap'
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
2026-10-27 14:03:01 +02:00
joshuaandClaude Opus 5.5 08d442e1c1 Handover and roadmap: Cross Events rename, Request update, versions moved up
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
1.41.3 and 1.42.0 / Hub 1.1.0 recorded; Multi-campus is now 1.43.0 and
later phases move up by one. Next steps: roll out update requests, build
the Hub public key in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:03:00 +02:00
joshua be4f341e42 Merge branch 'feat/request-update'
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
2026-10-27 13:55:06 +02:00
joshuaandClaude Opus 5.5 1caa0f0ea7 Request update button instead of the deploy command (1.42.0)
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
Site settings -> Licence sends a signed update request to the Hub
(docs/hub-protocol.md 3a) instead of showing bash scripts/deploy.sh. Shows
"Update requested" until the site runs that version and hides the update
banner meanwhile. Audit-logged as hub_update_requested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:55:05 +02:00
joshua 91f6481fb7 Merge branch 'fix/cross-events-name'
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
2026-10-27 13:06:22 +02:00
joshuaandClaude Opus 5.5 bf887720a1 Bump to 1.41.3
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:06:21 +02:00
joshuaandClaude Opus 5.5 6de0b5525b Rename the product to Cross Events in UI text and docs
Display text only: storage keys, slugs, the encryption salt, the repo URL
and applied migrations keep their old names so existing installs keep working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:06:20 +02:00
joshua e261705214 Merge branch 'docs/frp-deployment'
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
2026-10-27 12:11:14 +02:00
joshuaandClaude Opus 5.5 4ef9cea634 Deployment guide for installs behind an frp tunnel
DEPLOYMENT.md: install with --no-ssl, switch URLs to https, TRUST_PROXY,
nginx and /etc/frp/frpc.ini for TLS on the VPS or in frpc (https2http),
checking visitor IPs, updates and troubleshooting. Linked from the README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:11:11 +02:00
joshua 6f18e40f06 Merge branch 'fix/job-times'
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
2026-10-27 11:06:54 +02:00
joshuaandClaude Opus 5.5 7fdd2b8b9d Bump to 1.41.2
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:06:54 +02:00
joshuaandClaude Opus 5.5 8e2cb7eb4d Background jobs: show last-run times in the right time zone
pg-boss timestamps are timestamptz, and Prisma's pg adapter drops the offset in raw queries (10:55+02 came back as 10:55Z, shown as 12:55). Read them AT TIME ZONE 'UTC'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:06:51 +02:00
joshuaandClaude Opus 5.5 bc73d7e3af Document APP_TIMEZONE in .env.example and SETTINGS.md
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:49:16 +02:00
joshua 925252a1f2 Merge branch 'fix/licence-checks'
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
2026-10-27 10:45:41 +02:00
joshuaandClaude Opus 5.5 60726b71ce Bump to 1.41.1
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:45:40 +02:00
joshuaandClaude Opus 5.5 e58787ae7a Licence: no licence is read-only, editing events blocked, heartbeat 30 s after start
- A site the Hub answers INSTALL_NOT_FOUND for drops its install and licence, so it
  no longer shows "Active" on the old token.
- Unlicensed is read-only like suspended; read-only also blocks editing events,
  options and variants. LICENCE_CHECK_DISABLED (ignored in production) for e2e.
- Heartbeat 30 s after the server starts and after a licence key is connected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:45:38 +02:00
75 changed files with 2372 additions and 212 deletions
+89
View File
@@ -7,6 +7,95 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.43.0] - 2026-10-27
### Added
- **Campuses** (Site settings → Campuses, off by default) for a church with more than one campus
in one install. Churches with one campus see no campus fields anywhere.
- Admins turn campuses on, then add, order, archive and delete campuses: name, web address,
address, contact, colour, logo, default event location, and staff who get that campus's daily
summary. A campus with events can't be deleted, only archived. Turning campuses off hides
everything campus-related and deletes nothing.
- **Events** belong to one campus or to **All campuses** (church-wide). Choosing a campus fills
in its default location; moving an event to another campus is in the audit log. The Events
list in the dashboard shows each event's campus and has a campus filter.
- **Public site:** a campus filter on the events page, remembered in the visitor's browser and
starting on their home campus; a campus shows its own events plus church-wide ones. The campus
shows on event cards, the event page and ticket PDFs. **Campus pages** at `/campus/<slug>`
(also in the sitemap).
- **Home campus** on the profile page.
- **Reports and dashboards:** a campus filter on Reports (all reports and the funnel) and on the
admin and supervisor dashboards' past-month tiles, with a church-wide total.
- **Broadcasts:** "Everyone at a campus" in Email and WhatsApp broadcasts — people whose home
campus it is, plus (ticked) people who registered for its events in the last 12 months. News
consent still applies.
- **Daily summary:** campus recipients also get the summary of their campus's events.
- The Hub heartbeat reports whether campuses are on and how many there are (`campusCount`).
- Campus-scoped staff (supervisors and staff limited to some campuses) follow in 1.44.0.
- The privacy notice lists the new `hope_events_campus` browser-storage item.
### Fixed
- "Request update" (1.42.0) wasn't written to the audit log: the `hub_update_requested` action was
missing from the database. The request itself always worked.
## [1.42.1] - 2026-10-27
### Changed
- **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
- **"Request update" instead of the deploy command.** When a new version is out, Site settings →
Licence shows a **Request update** button instead of `bash scripts/deploy.sh …`. It sends a
signed request to the Hub (`docs/hub-protocol.md` §3a), which tells the provider; the site never
updates itself. Afterwards it shows "Update requested on …" until the site runs that version, and
the "update available" banner for admins goes away. Logged in the audit log
(`hub_update_requested`). Needs Cross Events Hub 1.1.0; an older Hub answers "The Hub doesn't take
update requests yet".
## [1.41.3] - 2026-10-27
### Changed
- **The platform is now called Cross Events** (and the Hub, Cross Events Hub): Site settings →
Licence, the "update available" banner, the setup wizard, the audit log, help and the docs.
Technical names stay as they are (`hope-events` repo, PM2 and nginx names, install folder, browser
storage keys), so existing installs and sign-ins keep working.
## [1.41.2] - 2026-10-27
### Fixed
- **Background jobs page showed last-run times two hours ahead** (e.g. 12:55 at 10:55). Prisma's
Postgres adapter dropped the time-zone offset of pg-boss's timestamps; the query now reads them
as UTC. Job history itself was never wrong. `APP_TIMEZONE` is now in `backend/.env.example` and
SETTINGS.md.
## [1.41.1] - 2026-10-27
### Changed
- **No licence now means read-only**, the same as a suspended licence: a site that isn't connected
to the Hub can't create events, registrations or payments until a licence key is connected
(admins see a red banner). `LICENCE_CHECK_DISABLED=true` switches this off outside production,
for the end-to-end tests.
- **Read-only mode also blocks editing events**, their options and their variants (suspended,
revoked and unlicensed sites).
- The site **checks in with the Hub 30 seconds after starting**, and 30 seconds after a licence
key is connected, instead of waiting up to 15 minutes.
### Fixed
- A site the Hub no longer knows (`INSTALL_NOT_FOUND`, e.g. the licence was released) kept showing
"Active" on its old licence. It now drops the connection and licence, and shows "No licence".
## [1.41.0] - 2026-10-26
### Added
+342
View File
@@ -0,0 +1,342 @@
# Deploying Cross Events behind frp
This guide is for a server that is **not reachable from the internet directly**: it runs
`frpc`, which opens a tunnel to `frps` on a public server (the "VPS"). Visitors reach the VPS,
and frp carries the traffic through to this server. The frp client config lives at
**`/etc/frp/frpc.ini`**.
For a server with a public IP, `scripts/install.sh` does everything (see the README). Behind frp
it needs a few changes, because:
- **HTTPS can't be set up by the installer.** DNS points at the VPS, not at this server, so
`certbot --nginx` can't work the usual way. HTTPS ends either on the VPS or in `frpc`.
- **There are more proxies in the way.** The app must be told which of them to trust, or it sees
every visitor as `127.0.0.1` (rate limiting then treats everyone as one person, and PayFast
payment notifications fail their IP check).
- **The app must know the site is HTTPS** even though nginx on this server only speaks HTTP;
otherwise it builds `http://` links (event images, payment return URLs).
## 1. How the traffic flows
Pick the setup that matches how your other frp sites get HTTPS.
**A. HTTPS ends on the VPS** (nginx/Caddy on the VPS holds the certificate) — recommended if the
VPS already does this for other sites:
```
visitor ──https──▶ VPS nginx/Caddy ──http──▶ frps (vhost_http_port) ══tunnel══▶ frpc ──▶ nginx :80 ──▶ app
```
**B. HTTPS ends in frpc** (the VPS only runs frps; the certificate lives on this server and
frpc's `https2http` plugin uses it):
```
visitor ──https──▶ frps (vhost_https_port) ══tunnel══▶ frpc https2http ──▶ nginx 127.0.0.1:8080 ──▶ app
visitor ──http───▶ frps (vhost_http_port) ══tunnel══▶ frpc ──▶ nginx :80 (certificate check + redirect to https)
```
On this server, nginx sends `/api`, `/uploads`, `/health` and `/docs` to the backend (port 5000)
and everything else to the Next.js site (port 3000), the same as a normal install.
## 2. Before you start
- Ubuntu 22.04 or 24.04 on this server, with `frpc` already installed and connected (it runs as
a systemd service, usually `frpc.service`).
- The site's DNS record (e.g. `events.yourchurch.org`) points at the **VPS**.
- On the VPS, `frps.ini` has `vhost_http_port` set (and `vhost_https_port` for setup B). Note
the values; you need them below.
- The licence key for this site from the Cross Events Hub.
## 3. Install the app
On this server, as root, run the installer **without** its certificate step:
```bash
curl -fsSLO https://c-git.crosscode.co.za/joshua/hope-events/raw/branch/main/scripts/install.sh
sudo bash install.sh --domain events.yourchurch.org --no-ssl --version v1.41.2
```
It installs Node 20, PostgreSQL, PM2 and nginx, creates the database, writes the config, builds
and starts the app (in `/opt/hope-events`, run by the `hopeevents` user). Use `--name`, `--dir`,
`--web-port` and `--api-port` if other apps already use those names or ports; adjust the ports
below to match.
### Switch the URLs to https
With `--no-ssl` the installer writes `http://` addresses. Change them to `https://`:
```bash
cd /opt/hope-events
sudo sed -i 's#=http://#=https://#' backend/.env frontend/.env.production
grep -E 'URL=' backend/.env frontend/.env.production # all should now start with https://
```
While `backend/.env` is open, set which proxies to trust (all of them run on loopback in both
setups; see [step 7](#7-check-that-visitor-addresses-come-through) to confirm):
```bash
sudo sed -i 's#^TRUST_PROXY=.*#TRUST_PROXY=loopback#' backend/.env
```
`NEXT_PUBLIC_API_URL` is baked in when the site is built, so rebuild the frontend and restart:
```bash
sudo -u hopeevents -H bash -lc 'cd /opt/hope-events/frontend && npm run build'
sudo -u hopeevents -H bash -lc 'cd /opt/hope-events && pm2 reload ecosystem.config.js --update-env'
```
Keep `JWT_SECRET` in `backend/.env` as it is: it also encrypts the secrets stored in the
database (SMTP, payment keys).
## 4. nginx on this server
The installer wrote `/etc/nginx/sites-available/hope-events`. Replace it with the version for
your setup, changing the domain (and the ports if you changed them).
### Setup A (HTTPS on the VPS)
```nginx
# Shows the forwarded-for chain in the access log (see step 7). Safe to keep.
log_format hope_proxy '$remote_addr xff="$http_x_forwarded_for" "$request" $status';
server {
listen 80;
server_name events.yourchurch.org;
client_max_body_size 25m;
access_log /var/log/nginx/hope-events.access.log hope_proxy;
# Visitors only reach this through the VPS over HTTPS, so tell the app so.
location ~ ^/(api|uploads|health|docs)(/|$) {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
```
The VPS nginx/Caddy must redirect plain HTTP to HTTPS and pass the `Host` header through to frps
unchanged (frps picks the site by its host name). For nginx on the VPS, roughly:
```nginx
server {
listen 443 ssl;
server_name events.yourchurch.org;
# ssl_certificate / ssl_certificate_key from certbot on the VPS
client_max_body_size 25m;
location / {
proxy_pass http://127.0.0.1:8080; # frps vhost_http_port
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
### Setup B (HTTPS in frpc)
Port 80 only answers certificate checks and redirects everything else; the real site is on
`127.0.0.1:8080`, which only frpc's `https2http` plugin reaches.
```nginx
log_format hope_proxy '$remote_addr xff="$http_x_forwarded_for" "$request" $status';
server {
listen 80;
server_name events.yourchurch.org;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 127.0.0.1:8080;
server_name events.yourchurch.org;
client_max_body_size 25m;
access_log /var/log/nginx/hope-events.access.log hope_proxy;
location ~ ^/(api|uploads|health|docs)(/|$) {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
```
Then, for either setup:
```bash
sudo mkdir -p /var/www/certbot # setup B only
sudo nginx -t && sudo systemctl reload nginx
```
## 5. frpc: `/etc/frp/frpc.ini`
Back it up first, then **add** a section for the site below the existing ones. Leave `[common]`
(server address, port, token) and the other sections as they are. Section names must be unique
across everything connected to your frps.
```bash
sudo cp /etc/frp/frpc.ini /etc/frp/frpc.ini.bak-$(date +%F)
sudo nano /etc/frp/frpc.ini
```
### Setup A
```ini
[hope-events-http]
type = http
local_ip = 127.0.0.1
local_port = 80
custom_domains = events.yourchurch.org
```
### Setup B
```ini
# Plain HTTP: certificate checks and the redirect to https
[hope-events-http]
type = http
local_ip = 127.0.0.1
local_port = 80
custom_domains = events.yourchurch.org
# HTTPS: frpc holds the certificate and passes plain HTTP to nginx on 127.0.0.1:8080
[hope-events-https]
type = https
custom_domains = events.yourchurch.org
plugin = https2http
plugin_local_addr = 127.0.0.1:8080
plugin_crt_path = /etc/letsencrypt/live/events.yourchurch.org/fullchain.pem
plugin_key_path = /etc/letsencrypt/live/events.yourchurch.org/privkey.pem
plugin_header_X-Forwarded-Proto = https
```
Restart frpc and check it registered the new sections without errors:
```bash
sudo systemctl restart frpc
sudo journalctl -u frpc -n 30 --no-pager # look for "start proxy success" for each section
```
> Newer frp releases (0.52+) prefer `frpc.toml`, but still read `.ini`. Don't mix the two: if
> your service already uses `frpc.ini`, keep adding to it.
### Certificate (setup B only)
Request it through the tunnel with the webroot method (not `--nginx`, which would rewrite the
nginx config), then have renewals restart frpc so it loads the new certificate:
```bash
sudo apt-get install -y certbot
sudo certbot certonly --webroot -w /var/www/certbot -d events.yourchurch.org \
-m you@yourchurch.org --agree-tos --non-interactive
echo 'systemctl restart frpc' | sudo tee /etc/letsencrypt/renewal-hooks/deploy/restart-frpc.sh
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/restart-frpc.sh
sudo systemctl restart frpc
```
frpc needs to be able to read the certificate files (it does when it runs as root, the usual
setup). If the `[hope-events-https]` section fails to start, check `journalctl -u frpc`.
## 6. Finish in the browser
1. Open `https://events.yourchurch.org/health` — it should answer with the version.
2. Open `https://events.yourchurch.org/setup`: create the admin account.
3. **Site settings → Licence**: enter the licence key to connect to the Hub. The site checks in
every 15 minutes from then on (outgoing HTTPS to `events-hub.crosscode.co.za`, which frp
doesn't affect).
4. Configure SMTP, payments (Yoco/PayFast webhooks use the `https://` `BACKEND_URL`), branding,
etc. in Site settings.
## 7. Check that visitor addresses come through
Open the site from your phone (on mobile data, so it isn't your own network), then on the
server:
```bash
sudo tail -n 5 /var/log/nginx/hope-events.access.log
```
The `xff="…"` part is what the app sees, with nginx's own entry added at the end:
- `xff="<your phone's IP>, 127.0.0.1"` (only `127.0.0.1` after the real address): good,
`TRUST_PROXY=loopback` is right.
- `xff="<your phone's IP>, 203.0.113.10"` (a non-local address after it, e.g. the VPS's): add
that address: `TRUST_PROXY=loopback,203.0.113.10`, then
`pm2 reload ecosystem.config.js --update-env`.
- Your phone's IP doesn't appear at all: frp isn't passing the visitor's address on (can happen
with setup B on some frp versions). Rate limiting and the PayFast IP check will then misbehave.
Fix it by moving TLS to the VPS (setup A), or with frp's `proxy_protocol_version = v2` on the
frpc section plus nginx's `listen … proxy_protocol` and `set_real_ip_from 127.0.0.1;
real_ip_header proxy_protocol;`.
The backend logs the setting it uses at startup: `pm2 logs hope-events-backend | grep trust`.
## 8. Updating
Nothing about frp changes for updates. On the server:
```bash
cd /opt/hope-events
sudo -u hopeevents -H bash scripts/deploy.sh v1.41.2 # or no tag for the latest main
```
It backs up the database, checks out the release, installs, migrates, builds and reloads PM2,
and goes back to the previous version if anything fails first. `backend/.env`,
`frontend/.env.production`, `ecosystem.config.js` and the nginx/frpc configs are not in git, so
updates keep your changes from this guide.
### Deploying from CI over SSH
The Gitea workflow deploys on a `v*` tag by SSHing to `DEPLOY_HOST`. Behind frp, that needs an
SSH tunnel too, e.g. in `frpc.ini`:
```ini
[hope-events-ssh]
type = tcp
local_ip = 127.0.0.1
local_port = 22
remote_port = 6022
```
Then use the VPS as `DEPLOY_HOST`. The workflow's `ssh` call (`.gitea/workflows/ci.yml`) doesn't
take a port yet, so add `-p 6022` there (or a `DEPLOY_PORT` secret) before relying on it. Only
open this if you need it: it exposes this server's SSH on the VPS. Use key-only login. Deploying
by hand (above) needs none of this.
## Troubleshooting
| Symptom | Likely cause |
| --- | --- |
| frps shows "404 Not Found" page | The `custom_domains` value doesn't match the address typed, or frpc didn't start that section (`journalctl -u frpc`). |
| 502 Bad Gateway | nginx is up but the app isn't: `sudo -u hopeevents pm2 ls` and `pm2 logs`. |
| Site loads but login/API calls fail (CORS) | `FRONTEND_URL` in `backend/.env` is still `http://`, or the frontend wasn't rebuilt after changing `NEXT_PUBLIC_API_URL`. |
| Images or links use `http://` (browser "mixed content" warnings) | nginx isn't sending `X-Forwarded-Proto https`, or `TRUST_PROXY` isn't set. |
| "Too many requests" for everyone at once | All visitors look like one address: see step 7. |
| PayFast payments stay pending | Same as above: the PayFast notification's IP check sees the proxy, not PayFast. |
| Uploads over ~1 MB fail | `client_max_body_size` missing on this server's nginx or the VPS's. |
| Redirect loop (setup B) | The site's `server` block is on port 80 instead of `127.0.0.1:8080`, or `plugin_local_addr` points at port 80. |
+38 -11
View File
@@ -1,4 +1,4 @@
# Hope Events — Feature Overview
# Cross Events — Feature Overview
A complete map of what the platform does, grouped by who uses it. For setup and
deployment see [README.md](./README.md); for the API see
@@ -29,12 +29,13 @@ WhatsApp · PM2 for process management · optional Sentry for error monitoring.
13. [Cashup & event costs](#cashup--event-costs)
14. [Reports](#reports)
15. [Site settings & setup wizard](#site-settings--setup-wizard)
16. [Accounts & security](#accounts--security)
17. [Privacy (POPIA)](#privacy-popia)
18. [Help system](#help-system)
19. [Background jobs](#background-jobs)
20. [Operations](#operations)
21. [Page map](#page-map)
16. [Multi-campus](#multi-campus)
17. [Accounts & security](#accounts--security)
18. [Privacy (POPIA)](#privacy-popia)
19. [Help system](#help-system)
20. [Background jobs](#background-jobs)
21. [Operations](#operations)
22. [Page map](#page-map)
---
@@ -511,6 +512,7 @@ applied without a restart. Secrets are encrypted with AES-256-GCM.
| Tab | Contains |
|-----|----------|
| Organisation | Name, tagline, contact email/phone, address (with map link) |
| Campuses | Off by default. Turn on, then add, order, archive or delete campuses (name, web address, address, contact, colour, logo, default event location, daily-summary recipients) |
| Branding | Primary/secondary/accent colours, logo, favicon, colours suggested from the logo, live website/email preview on desktop and mobile |
| Notifications | Registrations inbox and admin notification emails |
| Email | SMTP host/port/TLS/user/password/from, with a "Test connection" button |
@@ -523,6 +525,28 @@ See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
---
## Multi-campus
For a church with several campuses in one install (Site settings → Campuses, off by
default — single-campus churches never see campus fields).
- **Events** belong to one campus or to **All campuses** (church-wide). Choosing a campus fills in
its default location. Moving an event to another campus is in the audit log.
- **Public site:** a campus filter on the events page, remembered per visitor and starting on their
home campus; a campus shows its own events plus church-wide ones. The campus shows on event cards,
the event page and ticket PDFs. Each campus has a page at `/campus/<slug>` (also in the sitemap).
- **People** choose a home campus on their profile.
- **Reports and dashboards:** a campus filter on Reports (every report and the funnel) and on the
admin/supervisor dashboards' month-over-month tiles, with a church-wide total.
- **Messages:** "Everyone at a campus" in Email and WhatsApp broadcasts — home campus, plus (ticked)
people who registered for its events in the last 12 months. News consent still applies.
- **Daily summary:** campus recipients get the summary of their campus's events.
- **Turning it off** hides everything campus-related; nothing is deleted. A campus with events can't
be deleted, only archived.
- Campus-scoped staff (limited to some campuses) come in 1.44.0.
---
## Accounts & security
- Register, log in, forgot/reset password, activate account (for accounts
@@ -642,7 +666,7 @@ can't start, the same jobs run on in-process timers.
| `consent` — parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false`) |
| `privacy` — remind admins of privacy requests due within a week or overdue | 08:00 daily (`PRIVACY_JOBS_ENABLED=false`) |
| `retention` — delete/anonymise personal information past its retention period (Admin → Privacy → Retention) | 03:30 daily (`RETENTION_JOBS_ENABLED=false`) |
| `hub-heartbeat` — check in with the Hope Events Hub and pick up licence changes (only once connected) | Every 15 minutes (`HUB_HEARTBEAT_ENABLED=false`) |
| `hub-heartbeat` — check in with the Cross Events Hub and pick up licence changes (only once connected) | Every 15 minutes (`HUB_HEARTBEAT_ENABLED=false`) |
| `analytics-rollup` — roll raw visit rows older than 90 days into daily totals | 04:15 daily (`ANALYTICS_ROLLUP_ENABLED=false`) |
| `backup` — database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false`) |
| `temp-cleanup` — temp files older than 7 days in `/temp` | 03:00 daily |
@@ -653,12 +677,14 @@ can't start, the same jobs run on in-process timers.
- **Install script** (`scripts/install.sh`) — a fresh Ubuntu server to a running site: Node 20,
PostgreSQL, PM2, nginx, HTTPS, configuration, migrations and build; then `/setup`.
- **Hope Events Hub** — every install connects with a licence key (`/setup`, or Site settings →
- **Cross Events Hub** — every install connects with a licence key (`/setup`, or Site settings →
Licence) and checks in every 15 minutes with its version, health and totals (no personal
information; income as a range). Licences are signed tokens checked on the site, so a Hub outage
never switches it off: past the end date there's a grace period (banner for site admins only),
then — or when suspended or revoked — the site is read-only (no new events, registrations or
payments; tickets still scan, data can be exported). Admins see when an update is available.
then — or when suspended, revoked or not licensed — the site is read-only (events can't be
created or edited; no new registrations or payments; tickets still scan, data can be exported).
The site checks in 30 seconds after starting and after a licence key is connected. Admins see when an update is available and press **Request update**,
which tells the provider through the Hub (the site never updates itself).
See `docs/hub-protocol.md`.
- **`/health`** — status, version, database, background jobs and last backup.
- **PM2** (`ecosystem.config.js`) runs backend and frontend, restarting either
@@ -687,6 +713,7 @@ can't start, the same jobs run on in-process timers.
|------|-----|------|
| `/` | Public | Home |
| `/events`, `/events/[id]` | Public | Event list and detail |
| `/campus/[slug]` | Public | A campus's page and its events (while campuses are on) |
| `/[alias]` | Public | Event short link |
| `/register/[eventId]` | Public / User | Registration |
| `/registration/success` | Public / User | Confirmation, form and pay |
+5 -3
View File
@@ -1,6 +1,6 @@
# Hope Events Platform
# Cross Events Platform
Hope Events is a full-stack event management platform that allows users to browse events, register for them, make payments, and manage their tickets.
Cross Events is a full-stack event management platform that allows users to browse events, register for them, make payments, and manage their tickets.
## Project Structure
@@ -111,9 +111,11 @@ It installs Node 20, PostgreSQL (with its own database and a random password), P
API, uploads, `/health` and `/docs` to the backend, everything else to the site) and an HTTPS
certificate, writes `backend/.env` and `frontend/.env.production`, migrates, builds and starts the
app. Then open `https://<domain>/setup`: create the admin account and enter the **licence key**
to connect the site to the Hope Events Hub. Several installs can share a server with different
to connect the site to the Cross Events Hub. Several installs can share a server with different
`--name`, `--dir`, `--web-port` and `--api-port`. Run `--help` for all options.
Behind an frp tunnel (no public IP on the server)? See [DEPLOYMENT.md](DEPLOYMENT.md).
### Updating
To update a running install, on the server in the repo folder:
+11 -3
View File
@@ -17,6 +17,7 @@ in-process settings cache TTL) without a server restart.
| `org_email` | *(blank)* | No fallback in most places. **Exception:** the Terms of Use / Privacy Policy pages default to `privacy@example.com` (an obvious placeholder) if unset — see the Legal section below. |
| `org_phone` / `org_address` | *(blank)* | No fallback — simply omitted wherever shown if unset. |
| `app_base_url` | `APP_BASE_URL` or `FRONTEND_URL` env var, else `http://localhost:3001` | Used to build links in emails (password reset, ticket delivery, etc.). Should always be set in production. |
| `APP_TIMEZONE` (env) | `Africa/Johannesburg` | The site's time zone (an IANA name, e.g. `Europe/London`): background job schedules (e.g. the 07:00 daily summary), session dates and printed times. Also sent to the Hub in the heartbeat. Needs a backend restart. |
## Branding
@@ -84,14 +85,21 @@ 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. |
## Hope Events Hub (licence)
## 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 |
|---|---|---|
| `HUB_URL` (env) | `https://events-hub.crosscode.co.za` | Where the Hub runs. |
| `HUB_PUBLIC_KEY` (env) | The production key built into `backend/src/hub/publicKey.js` | Only for a local or test Hub. Licence tokens are checked with this key. |
| `HUB_HEARTBEAT_ENABLED` (env) | `true` | The 15-minute `hub-heartbeat` job. Off means the licence isn't refreshed — after 30 days the site goes into grace, then read-only. |
| `hub_install_id`, `hub_install_secret` (encrypted), `hub_licence`, `hub_last_heartbeat_at`, `hub_last_error`, `hub_latest_version` | — | Written only by `src/hub/client.js` when connecting and on each heartbeat. Never readable or writable through the generic settings endpoints. Deleting them disconnects the site (it becomes "not connected"). |
| `HUB_HEARTBEAT_ENABLED` (env) | `true` | The 15-minute `hub-heartbeat` job (plus one check-in 30 s after starting and after connecting). Off means the licence isn't refreshed — after 30 days the site goes into grace, then read-only. |
| `LICENCE_CHECK_DISABLED` (env) | `false` | `true` turns read-only mode off — **ignored when `NODE_ENV=production`**. For the end-to-end tests and local development without a Hub. |
| `hub_install_id`, `hub_install_secret` (encrypted), `hub_licence`, `hub_last_heartbeat_at`, `hub_last_error`, `hub_latest_version` | — | Written only by `src/hub/client.js` when connecting and on each heartbeat. Never readable or writable through the generic settings endpoints. Deleting them disconnects the site (it becomes "no licence", which is read-only). The heartbeat deletes the install and licence keys itself when the Hub answers `INSTALL_NOT_FOUND`. |
## WhatsApp
+5 -1
View File
@@ -13,6 +13,10 @@ JWT_SECRET=your_jwt_secret_here_minimum_32_characters
PORT=5000
NODE_ENV=development
# Time zone for background job schedules (e.g. the 07:00 daily summary), session dates
# and printed times. An IANA name, e.g. Africa/Johannesburg or Europe/London.
APP_TIMEZONE=Africa/Johannesburg
# Set this if the app runs behind a reverse proxy (e.g. nginx on a separate
# server) so rate limiting reads the real client IP instead of the proxy's.
# Accepts a hop count ("1"), "true"/"false", or comma-separated IP(s)/CIDR(s)
@@ -77,7 +81,7 @@ PRIVACY_JOBS_ENABLED=true
# accounts are anonymised after 5 years by default (with a warning 30 days before).
RETENTION_JOBS_ENABLED=true
# ─── Hope Events Hub (licence and health) ─────────────────────────────────────
# ─── Cross Events Hub (licence and health) ─────────────────────────────────────
# Where the Hub runs. The site checks in every 15 minutes once connected (Site settings → Licence).
# HUB_URL=https://events-hub.crosscode.co.za
# For a local or test Hub only: its public key (the release has the production key built in).
+26 -2
View File
@@ -1,4 +1,4 @@
# Hope Events — API Documentation
# Cross Events — API Documentation
Full interactive docs (with request/response examples) are available at `GET /docs?token=<admin-jwt>` on the running backend.
@@ -839,7 +839,7 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
## Hub and licence
The connection to the Hope Events Hub (`src/hub/`, protocol in `docs/hub-protocol.md`).
The connection to the Cross Events Hub (`src/hub/`, protocol in `docs/hub-protocol.md`).
| Method | Path | Auth | Description |
|--------|------|------|-------------|
@@ -847,9 +847,33 @@ The connection to the Hope Events Hub (`src/hub/`, protocol in `docs/hub-protoco
| GET | `/api/hub/status` | admin | `{ state: active\|grace\|suspended\|revoked\|unlicensed, readOnly, daysLeft, endsAt, graceEndsAt, licence, connected, keyConfigured, hubUrl, lastHeartbeatAt, lastError, version, latestVersion, updateAvailable }` |
| POST | `/api/hub/connect` | admin (incl. the `/setup` token) | `{ licenceKey }` — registers with the Hub; stores the install id, encrypted secret and licence token. Hub errors pass through with their `code` (`LICENCE_NOT_FOUND`, `LICENCE_IN_USE`, `LICENCE_REVOKED`, `HUB_UNREACHABLE`, …). Audited as `hub_connected` |
| POST | `/api/hub/heartbeat` | admin | Check in now; returns the status |
| POST | `/api/hub/update-request` | admin | Ask the Hub for this site to be updated (protocol §3a); the provider is told, nothing runs on the site. Returns the status with `updateRequest: { requestedAt, version }` until the site runs that version. `409 UP_TO_DATE`; audited as `hub_update_requested` |
**Read-only mode** (licence suspended, revoked, or past its grace period): `403 { code: "LICENCE_READ_ONLY" }` on `POST /api/events`, event options and variants, `POST /api/registrations` (and `/manual`), `PUT /api/registrations/:id/options`, `POST /api/payments`, `/payments/checkout` (`/send`), `/payments/yoco-checkout` (`/send`), `/payments/pay/:id`, `POST /api/waitlist`, `POST /api/coupons`, `POST /api/bundles/event/:id`. Everything else — scanning, exports, refunds, cancellations, privacy requests, settings — keeps working, and payment webhooks for payments already started are still processed.
## Campuses
Multi-campus (`src/utils/campuses.js`), off by default. While off, every campus field and filter
below is ignored and `GET /api/campuses` returns `{ enabled: false, campuses: [] }`.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/campuses` | public | `{ enabled, campuses }` — active campuses in order |
| GET | `/api/campuses/by-slug/:slug` | public | One active campus (its `/campus/<slug>` page); 404 when archived or off |
| GET | `/api/campuses/admin` | admin | All campuses incl. archived, with `notifyRecipients` and `_count { events, homeOf }` |
| PUT | `/api/campuses/settings` | admin | `{ enabled }` — audited as `campuses_toggled` (the `campuses_enabled` setting can't be changed through `PUT /api/settings`) |
| POST | `/api/campuses` | admin | `{ name, slug?, address?, contactName?, contactEmail?, contactPhone?, colour? (#rrggbb), logoUrl?, defaultLocation? }`; 409 when the slug is taken |
| PUT | `/api/campuses/:id` | admin | Same fields, plus `notifyRecipientIds` (staff only) and `archived` |
| PUT | `/api/campuses/order` | admin | `{ ids: [...] }` |
| DELETE | `/api/campuses/:id` | admin | Only without events — otherwise `409 CAMPUS_HAS_EVENTS` (archive instead) |
| GET | `/api/campuses/:id/audience` | supervisor+ | `{ campus, users }` — home campus, plus `?registrants=true` people registered for its events in the last 12 months (not cancelled). Broadcasts send to these ids as usual, so news consent still applies |
**Campus on other endpoints:**
- Events carry `campusId` (null = all campuses) and `campus { id, name, slug, colour }`. `POST`/`PUT /api/events` take `campusId` (`''`/`null` = all campuses; an archived campus can stay but can't be newly chosen); moving an event is audited as `event_campus_changed`.
- `GET /api/events?campus=<slug|id>` — that campus's events plus church-wide ones. `GET /api/events/all?campus=<id|slug|all>` — one campus, or church-wide only.
- `GET /api/stats/overview?campus=<id|slug|all>`.
- `PUT /api/users/profile` takes `homeCampusId`; `GET` returns it.
## Webhooks
| Method | Path | Auth | Description |
+2 -2
View File
@@ -1,6 +1,6 @@
# Hope Events — Backend API
# Cross Events — Backend API
Node.js + Express REST API for the Hope Events platform used by Hope Family Church (`events.hopehenley.co.za`).
Node.js + Express REST API for the Cross Events platform used by Hope Family Church (`events.hopehenley.co.za`).
---
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.41.0",
"version": "1.43.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.41.0",
"version": "1.43.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.41.0",
"version": "1.43.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,76 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AdminAuditAction" ADD VALUE 'hub_update_requested';
ALTER TYPE "AdminAuditAction" ADD VALUE 'campuses_toggled';
ALTER TYPE "AdminAuditAction" ADD VALUE 'campus_created';
ALTER TYPE "AdminAuditAction" ADD VALUE 'campus_updated';
ALTER TYPE "AdminAuditAction" ADD VALUE 'campus_archived';
ALTER TYPE "AdminAuditAction" ADD VALUE 'campus_deleted';
ALTER TYPE "AdminAuditAction" ADD VALUE 'event_campus_changed';
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "campusId" TEXT;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "homeCampusId" TEXT;
-- CreateTable
CREATE TABLE "Campus" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"address" TEXT,
"contactName" TEXT,
"contactEmail" TEXT,
"contactPhone" TEXT,
"colour" TEXT,
"logoUrl" TEXT,
"defaultLocation" TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
"archivedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Campus_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_CampusNotifyRecipients" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_CampusNotifyRecipients_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE UNIQUE INDEX "Campus_slug_key" ON "Campus"("slug");
-- CreateIndex
CREATE INDEX "Campus_order_idx" ON "Campus"("order");
-- CreateIndex
CREATE INDEX "_CampusNotifyRecipients_B_index" ON "_CampusNotifyRecipients"("B");
-- CreateIndex
CREATE INDEX "Event_campusId_idx" ON "Event"("campusId");
-- CreateIndex
CREATE INDEX "User_homeCampusId_idx" ON "User"("homeCampusId");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_homeCampusId_fkey" FOREIGN KEY ("homeCampusId") REFERENCES "Campus"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Event" ADD CONSTRAINT "Event_campusId_fkey" FOREIGN KEY ("campusId") REFERENCES "Campus"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_CampusNotifyRecipients" ADD CONSTRAINT "_CampusNotifyRecipients_A_fkey" FOREIGN KEY ("A") REFERENCES "Campus"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_CampusNotifyRecipients" ADD CONSTRAINT "_CampusNotifyRecipients_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+46
View File
@@ -141,6 +141,41 @@ model User {
noticeAcceptances NoticeAcceptance[]
dataAccessLogs DataAccessLog[]
breachesRecorded SecurityBreach[]
// Multi-campus (only used while Site settings → Campuses is on).
homeCampusId String?
homeCampus Campus? @relation("UserHomeCampus", fields: [homeCampusId], references: [id], onDelete: SetNull)
campusSummaries Campus[] @relation("CampusNotifyRecipients")
@@index([homeCampusId])
}
// A campus of the church (Site settings → Campuses, off by default). Events belong to one
// campus, or to all campuses when Event.campusId is null. Archived instead of deleted once
// it has events.
model Campus {
id String @id @default(uuid())
name String
slug String @unique
address String?
contactName String?
contactEmail String?
contactPhone String?
// Accent colour (#rrggbb) and logo shown with the campus on the public site.
colour String?
logoUrl String?
// Pre-fills Event.location when an event is created for this campus.
defaultLocation String?
order Int @default(0)
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
events Event[]
homeOf User[] @relation("UserHomeCampus")
// Get the daily summary for this campus's events (on top of each event's own recipients).
notifyRecipients User[] @relation("CampusNotifyRecipients")
@@index([order])
}
model Event {
@@ -187,6 +222,9 @@ model Event {
contactPhone String?
contactEmail String?
location String?
// Multi-campus: null = all campuses (also every event while campuses are off).
campusId String?
campus Campus? @relation(fields: [campusId], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String?
@@ -246,6 +284,7 @@ model Event {
@@index([createdById])
@@index([closedById])
@@index([reopenedById])
@@index([campusId])
}
model EventOption {
@@ -603,6 +642,13 @@ enum AdminAuditAction {
breach_recorded
breach_updated
hub_connected
hub_update_requested
campuses_toggled
campus_created
campus_updated
campus_archived
campus_deleted
event_campus_changed
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
+187
View File
@@ -0,0 +1,187 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { getClientIp } = require('../utils/requestUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
const { SETTING_KEY, CAMPUS_PUBLIC_SELECT, campusesEnabled, cleanCampusInput, campusAudienceWhere } = require('../utils/campuses');
const audit = (req, action, campus, metadata) => logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'Campus', targetId: campus?.id, metadata: { name: campus?.name, ...metadata }, ip: getClientIp(req),
});
// A slug that's already taken → a message instead of a Prisma error.
function friendlyError(e) {
if (e?.code === 'P2002') return 'Another campus already uses that web address — choose a different one.';
return e.message || safeErrorMessage(e);
}
const ADMIN_INCLUDE = {
notifyRecipients: { select: { id: true, name: true, email: true } },
_count: { select: { events: true, homeOf: true } },
};
// @desc Whether campuses are on, and the active campuses (public site filters, event cards)
// @route GET /api/campuses
// @access Public
const getCampuses = async (req, res) => {
try {
const enabled = await campusesEnabled();
const campuses = enabled
? await prisma.campus.findMany({ where: { archivedAt: null }, select: CAMPUS_PUBLIC_SELECT, orderBy: [{ order: 'asc' }, { name: 'asc' }] })
: [];
res.json({ enabled, campuses });
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc A campus landing page: the campus (active only)
// @route GET /api/campuses/by-slug/:slug
// @access Public
const getCampusBySlug = async (req, res) => {
try {
const campus = (await campusesEnabled())
? await prisma.campus.findFirst({ where: { slug: String(req.params.slug), archivedAt: null }, select: CAMPUS_PUBLIC_SELECT })
: null;
if (!campus) return res.status(404).json({ message: 'Campus not found' });
res.json(campus);
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc All campuses with archived ones, event counts and summary recipients (Site settings → Campuses)
// @route GET /api/campuses/admin
// @access Private/Admin
const getCampusesAdmin = async (req, res) => {
try {
const [enabled, campuses] = await Promise.all([
campusesEnabled(),
prisma.campus.findMany({ include: ADMIN_INCLUDE, orderBy: [{ archivedAt: { sort: 'asc', nulls: 'first' } }, { order: 'asc' }, { name: 'asc' }] }),
]);
res.json({ enabled, campuses });
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc Turn campuses on or off (off hides campus fields and filters; nothing is deleted)
// @route PUT /api/campuses/settings body: { enabled }
// @access Private/Admin
const updateCampusSettings = async (req, res) => {
try {
const enabled = req.body?.enabled === true;
await prisma.appSetting.upsert({ where: { key: SETTING_KEY }, update: { value: String(enabled) }, create: { key: SETTING_KEY, value: String(enabled) } });
invalidateSettingsCache();
await warmCache();
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'campuses_toggled', targetType: 'AppSetting', metadata: { enabled }, ip: getClientIp(req) });
res.json({ enabled });
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc Add a campus (added at the end of the list)
// @route POST /api/campuses
// @access Private/Admin
const createCampus = async (req, res) => {
try {
const { data, error } = cleanCampusInput(req.body);
if (error) return res.status(400).json({ message: error });
const last = await prisma.campus.aggregate({ _max: { order: true } });
const campus = await prisma.campus.create({ data: { ...data, order: (last._max.order ?? -1) + 1 }, include: ADMIN_INCLUDE });
audit(req, 'campus_created', campus, { slug: campus.slug });
res.status(201).json(campus);
} catch (e) {
res.status(e?.code === 'P2002' ? 409 : 400).json({ message: friendlyError(e) });
}
};
// @desc Change a campus: details, summary recipients, archived or not
// @route PUT /api/campuses/:id body: campus fields, notifyRecipientIds?, archived?
// @access Private/Admin
const updateCampus = async (req, res) => {
try {
const existing = await prisma.campus.findUnique({ where: { id: req.params.id } });
if (!existing) return res.status(404).json({ message: 'Campus not found' });
const { data, error } = cleanCampusInput(req.body, { partial: true });
if (error) return res.status(400).json({ message: error });
if (Array.isArray(req.body?.notifyRecipientIds)) {
const ids = req.body.notifyRecipientIds.filter(x => typeof x === 'string' && x);
// Summaries go to people who run events — never to ordinary users.
const staff = await prisma.user.findMany({ where: { id: { in: ids }, role: { in: ['admin', 'supervisor', 'staff'] } }, select: { id: true } });
data.notifyRecipients = { set: staff.map(u => ({ id: u.id })) };
}
let archiveChange = null;
if (req.body?.archived !== undefined) {
const archived = req.body.archived === true;
if (archived !== !!existing.archivedAt) {
data.archivedAt = archived ? new Date() : null;
archiveChange = archived;
}
}
const campus = await prisma.campus.update({ where: { id: existing.id }, data, include: ADMIN_INCLUDE });
if (archiveChange !== null) audit(req, 'campus_archived', campus, { archived: archiveChange });
const changed = Object.keys(data).filter(k => k !== 'archivedAt');
if (changed.length) audit(req, 'campus_updated', campus, { changed });
res.json(campus);
} catch (e) {
res.status(e?.code === 'P2002' ? 409 : 400).json({ message: friendlyError(e) });
}
};
// @desc Put the campuses in this order (filters and lists follow it)
// @route PUT /api/campuses/order body: { ids: [...] }
// @access Private/Admin
const reorderCampuses = async (req, res) => {
try {
const ids = Array.isArray(req.body?.ids) ? req.body.ids.filter(x => typeof x === 'string') : [];
if (!ids.length) return res.status(400).json({ message: 'Send the campus ids in their new order.' });
await prisma.$transaction(ids.map((id, order) => prisma.campus.updateMany({ where: { id }, data: { order } })));
res.json({ ok: true });
} catch (e) {
res.status(400).json({ message: safeErrorMessage(e) });
}
};
// @desc Delete a campus that has no events (otherwise archive it). Home campus is cleared.
// @route DELETE /api/campuses/:id
// @access Private/Admin
const deleteCampus = async (req, res) => {
try {
const campus = await prisma.campus.findUnique({ where: { id: req.params.id }, include: { _count: { select: { events: true } } } });
if (!campus) return res.status(404).json({ message: 'Campus not found' });
if (campus._count.events > 0) {
return res.status(409).json({ code: 'CAMPUS_HAS_EVENTS', message: `${campus.name} has ${campus._count.events} event${campus._count.events === 1 ? '' : 's'}, so it can't be deleted. Archive it instead.` });
}
await prisma.campus.delete({ where: { id: campus.id } });
audit(req, 'campus_deleted', campus, { slug: campus.slug });
res.json({ message: 'Campus deleted' });
} catch (e) {
res.status(400).json({ message: safeErrorMessage(e) });
}
};
// @desc "Everyone at campus X" for the email/WhatsApp composers: home campus X, plus (with
// ?registrants=true) people registered for X's events in the last 12 months. The
// composer sends to these ids as usual, so news consent is still checked on sending.
// @route GET /api/campuses/:id/audience?registrants=true
// @access Private/Supervisor
const getCampusAudience = async (req, res) => {
try {
const campus = await prisma.campus.findUnique({ where: { id: req.params.id }, select: { id: true, name: true } });
if (!campus) return res.status(404).json({ message: 'Campus not found' });
const users = await prisma.user.findMany({
where: campusAudienceWhere(campus.id, { includeRegistrants: req.query.registrants === 'true' }),
select: { id: true, name: true, email: true, phoneNumber: true },
orderBy: { name: 'asc' },
});
res.json({ campus, users });
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
module.exports = { getCampusAudience, getCampuses, getCampusBySlug, getCampusesAdmin, updateCampusSettings, createCampus, updateCampus, reorderCampuses, deleteCampus };
+28 -3
View File
@@ -8,6 +8,7 @@ const { assertEventOpen } = require('../utils/cashupUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const sessionsUtil = require('../utils/sessions');
const { campusesEnabled, campusForEvent, campusWhere, resolveCampusFilter, EVENT_CAMPUS_SELECT } = require('../utils/campuses');
const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' }, include: { _count: { select: { usages: true } } } };
@@ -125,6 +126,9 @@ const createEvent = async (req, res) => {
try {
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
const campusPick = await campusForEvent(req.body.campusId, { enabled: await campusesEnabled() });
if (campusPick.error) { const err = new Error(campusPick.error); err.statusCode = 400; throw err; }
const data = {
id: uuidv4(),
title,
@@ -160,6 +164,7 @@ const createEvent = async (req, res) => {
contactPhone: contactPhone || null,
contactEmail: contactEmail || null,
location: location || null,
...(campusPick.campusId !== undefined ? { campusId: campusPick.campusId } : {}),
...scheduleData(req.body),
};
if (data.scheduleType === 'sessions') {
@@ -325,15 +330,19 @@ const getEvents = async (req, res) => {
// Determine if Prisma Client supports EarlyBirdTier relation (i.e., migration+generate applied)
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
const eventOptionsInclude = canIncludeTiers ? { include: { earlyBirdTiers: true } } : true;
// ?campus=<slug or id>: that campus's events plus church-wide ones.
const enabled = await campusesEnabled();
const campusFilter = campusWhere(enabled ? await resolveCampusFilter(req.query.campus) : '', { enabled, withAllCampuses: true });
try {
events = await prisma.event.findMany({
where: {
isActive: true,
isHidden: false,
goLiveAt: { lte: new Date() },
endDate: { gte: new Date() }
endDate: { gte: new Date() },
...campusFilter,
},
include: { eventOptions: eventOptionsInclude, sessions: SESSIONS_INCLUDE }
include: { eventOptions: eventOptionsInclude, sessions: SESSIONS_INCLUDE, campus: { select: EVENT_CAMPUS_SELECT } }
});
} catch (e) {
// Fallback if goLiveAt/isHidden not available yet (pre-migration)
@@ -383,6 +392,7 @@ const getAllEvents = async (req, res) => {
include: {
eventOptions: canIncludeTiers ? { include: { earlyBirdTiers: true } } : true,
sessions: SESSIONS_INCLUDE,
campus: { select: EVENT_CAMPUS_SELECT },
}
});
@@ -413,6 +423,9 @@ const getEventsAll = async (req, res) => {
if (!includeInactive) where.isActive = true;
if (!includePast) where.endDate = { gte: new Date() };
if (excludeClosed) where.cashupStatus = { not: 'closed' };
// ?campus=<id, slug or 'all'>: one campus's events, or only church-wide ones.
const enabled = await campusesEnabled();
Object.assign(where, campusWhere(enabled ? await resolveCampusFilter(req.query.campus) : '', { enabled }));
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
@@ -425,6 +438,7 @@ const getEventsAll = async (req, res) => {
eventOptions: optionInclude ? { include: optionInclude } : true,
form: { include: { fields: true } },
sessions: SESSIONS_INCLUDE,
campus: { select: EVENT_CAMPUS_SELECT },
},
orderBy: { startDate: 'asc' }
});
@@ -452,7 +466,7 @@ const getEventById = async (req, res) => {
const optionInclude = canIncludeTiers
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) }
: undefined;
const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: SESSIONS_INCLUDE };
const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: SESSIONS_INCLUDE, campus: { select: EVENT_CAMPUS_SELECT } };
// Staff/supervisor/admin need the creator + notify-recipient list to populate the
// event edit screen's Notifications step; public callers don't need this.
@@ -688,6 +702,10 @@ const updateEvent = async (req, res) => {
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
const campusPick = await campusForEvent(req.body.campusId, { enabled: await campusesEnabled(), currentCampusId: event.campusId });
if (campusPick.error) throw new Error(campusPick.error);
const campusChanged = campusPick.campusId !== undefined && campusPick.campusId !== event.campusId;
const data = {
title: title || event.title,
description: description !== undefined ? description : event.description,
@@ -725,6 +743,7 @@ const updateEvent = async (req, res) => {
contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone,
contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail,
location: location !== undefined ? (location || null) : event.location,
...(campusChanged ? { campusId: campusPick.campusId } : {}),
updatedAt: new Date(),
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
...scheduleData(req.body, event),
@@ -749,6 +768,12 @@ const updateEvent = async (req, res) => {
: null;
});
sessionsUtil.forgetSchedule(updatedEvent.id);
if (campusChanged) {
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'event_campus_changed',
targetType: 'Event', targetId: updatedEvent.id, metadata: { from: event.campusId, to: campusPick.campusId }, ip: getClientIp(req),
});
}
if (sessionChanges && Object.values(sessionChanges).some(n => n > 0)) {
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'event_sessions_changed',
+25 -1
View File
@@ -13,6 +13,12 @@ function compareVersions(a, b) {
return 0;
}
/** An update request still waiting: until this site runs the version that was asked for. */
function pendingUpdateRequest(version, requestedAt, requestedVersion) {
if (!requestedAt || !requestedVersion || compareVersions(version, requestedVersion) >= 0) return null;
return { requestedAt, version: requestedVersion };
}
/** What the admin pages show: licence state, Hub connection, version and update. */
async function statusPayload({ fresh = false } = {}) {
const s = await licenceStatus({ fresh });
@@ -40,6 +46,7 @@ async function statusPayload({ fresh = false } = {}) {
version: APP_VERSION,
latestVersion: s.latestVersion,
updateAvailable: !!(s.latestVersion && compareVersions(APP_VERSION, s.latestVersion) < 0),
updateRequest: pendingUpdateRequest(APP_VERSION, s.updateRequestedAt, s.updateRequestedVersion),
};
}
@@ -63,6 +70,7 @@ const connect = async (req, res) => {
if (!licenceKey) { res.status(400); throw new Error('Enter your licence key.'); }
const me = await prisma.user.findUnique({ where: { id: req.user.id }, select: { name: true, email: true } });
const claims = await hub.connect(licenceKey, me);
hub.heartbeatSoon();
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'hub_connected', targetType: 'hub', metadata: { customer: claims.customer, hubUrl: hub.hubUrl() }, ip: getClientIp(req) });
res.json(await statusPayload({ fresh: true }));
} catch (e) {
@@ -83,4 +91,20 @@ const heartbeatNow = async (req, res) => {
}
};
module.exports = { getStatus, connect, heartbeatNow, statusPayload, compareVersions };
// @desc Ask the Hub for this site to be updated (the provider is told; nothing runs here)
// @route POST /api/hub/update-request
// @access Private/Admin
const requestUpdate = async (req, res) => {
try {
const result = await hub.requestUpdate();
if (!result.alreadyRequested) {
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'hub_update_requested', targetType: 'hub', metadata: { from: APP_VERSION, to: result.version }, ip: getClientIp(req) });
}
res.json(await statusPayload({ fresh: true }));
} catch (e) {
const status = e.statusCode && e.statusCode < 500 && e.code !== 'INSTALL_NOT_FOUND' ? e.statusCode : 502;
res.status(status).json({ message: e.message || safeErrorMessage(e), code: e.code });
}
};
module.exports = { getStatus, connect, heartbeatNow, requestUpdate, statusPayload, compareVersions, pendingUpdateRequest };
@@ -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',
+30 -12
View File
@@ -1,6 +1,7 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { isDonationLeg } = require('../utils/cashupUtils');
const { campusesEnabled, campusWhere, resolveCampusFilter } = require('../utils/campuses');
// Shared building blocks for the per-dashboard stats endpoints below. Each dashboard
// (staff/supervisor/admin) gets exactly one endpoint that returns only what it renders,
@@ -53,8 +54,23 @@ function getRecentScans(limit = 10) {
});
}
function getActiveEventsCount() {
return prisma.event.count({ where: { isActive: true, endDate: { gte: new Date() } } });
function getActiveEventsCount(eventWhere = {}) {
return prisma.event.count({ where: { isActive: true, endDate: { gte: new Date() }, ...eventWhere } });
}
/**
* `where` pieces for a ?campus= filter (id, slug or 'all'), or nulls for church-wide. A payment
* belongs to a campus through its event, or its registration's event.
*/
async function campusFilters(value) {
const enabled = await campusesEnabled();
const eventWhere = campusWhere(enabled ? await resolveCampusFilter(value) : '', { enabled });
if (!Object.keys(eventWhere).length) return { eventWhere: {}, byEvent: {}, payment: {} };
return {
eventWhere,
byEvent: { event: eventWhere },
payment: { OR: [{ event: eventWhere }, { registration: { event: eventWhere } }] },
};
}
// A donation-application leg (isDonation:false, originalPaymentId set, amount>0) isn't new
@@ -207,21 +223,22 @@ function classifyAndSum(payments) {
// @desc Registrations/tickets/revenue/donations for the trailing month (today back one
// month) vs the month before that, a daily revenue trend for the trailing month,
// and the top performing events overall.
// @route GET /api/stats/overview
// @route GET /api/stats/overview?campus=<id|slug|all> (no campus = church-wide)
// @access Private/Supervisor+
const getOverviewStats = async (req, res) => {
try {
const now = new Date();
const { thisStart, thisEnd, lastStart, lastEnd } = trailingMonthRanges(now);
const cf = await campusFilters(req.query.campus);
const [thisPayments, lastPayments, thisRegistrations, lastRegistrations, thisTickets, lastTickets, activeEvents] = await Promise.all([
fetchPaymentsWithOriginal({ createdAt: { gte: thisStart, lt: thisEnd } }),
fetchPaymentsWithOriginal({ createdAt: { gte: lastStart, lt: lastEnd } }),
prisma.registration.count({ where: { createdAt: { gte: thisStart, lt: thisEnd }, status: { not: 'cancelled' } } }),
prisma.registration.count({ where: { createdAt: { gte: lastStart, lt: lastEnd }, status: { not: 'cancelled' } } }),
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: thisStart, lt: thisEnd } } }),
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: lastStart, lt: lastEnd } } }),
getActiveEventsCount(),
fetchPaymentsWithOriginal({ createdAt: { gte: thisStart, lt: thisEnd }, ...cf.payment }),
fetchPaymentsWithOriginal({ createdAt: { gte: lastStart, lt: lastEnd }, ...cf.payment }),
prisma.registration.count({ where: { createdAt: { gte: thisStart, lt: thisEnd }, status: { not: 'cancelled' }, ...cf.byEvent } }),
prisma.registration.count({ where: { createdAt: { gte: lastStart, lt: lastEnd }, status: { not: 'cancelled' }, ...cf.byEvent } }),
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: thisStart, lt: thisEnd }, ...cf.byEvent } }),
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: lastStart, lt: lastEnd }, ...cf.byEvent } }),
getActiveEventsCount(cf.eventWhere),
]);
const thisTotals = classifyAndSum(thisPayments);
@@ -245,7 +262,7 @@ const getOverviewStats = async (req, res) => {
// "real revenue" definition as the KPI above: everything except application legs.
const revenueByEvent = await prisma.payment.groupBy({
by: ['eventId'],
where: { ...EXCLUDE_DONATION_LEGS, eventId: { not: null } },
where: { ...EXCLUDE_DONATION_LEGS, eventId: { not: null }, ...cf.byEvent },
_sum: { amount: true },
orderBy: { _sum: { amount: 'desc' } },
take: 5,
@@ -270,7 +287,7 @@ const getOverviewStats = async (req, res) => {
// Funnel conversion (event page views → paid) across live events, last 30 days.
let funnel = null;
try {
const live = await prisma.event.findMany({ where: { isActive: true, endDate: { gte: now } }, select: { id: true } });
const live = await prisma.event.findMany({ where: { isActive: true, endDate: { gte: now }, ...cf.eventWhere }, select: { id: true } });
if (live.length) {
const { funnelFor } = require('../utils/analytics');
const f = await funnelFor(live.map(e => e.id), { from: new Date(now.getTime() - 30 * 864e5).toISOString() });
@@ -296,6 +313,7 @@ const getOverviewStats = async (req, res) => {
};
module.exports = {
campusFilters,
getStaffDashboardStats,
getSupervisorDashboardStats,
getAdminDashboardStats,
+9 -2
View File
@@ -103,7 +103,7 @@ const getUserTickets = async (req, res) => {
},
},
user: { select: { id: true, name: true } },
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } },
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } }, campus: { select: { name: true, slug: true } } } },
transferRequests: { where: { status: 'pending' }, select: { id: true, toName: true, expiresAt: true } },
usages: {
include: {
@@ -929,6 +929,12 @@ function drawTicketCard(doc, { x, y, w, h }, ticket, qrDataUrl, buyerName) {
doc.text(ticket.event.title, x + pad, cy, { width: titleW });
cy += doc.heightOfString(ticket.event.title, { width: titleW }) + 4;
// Campus (only included while campuses are on)
if (ticket.event.campus?.name) {
doc.font('Helvetica').fontSize(9).fillColor(gray500).text(ticket.event.campus.name, x + pad, cy, { width: innerW });
cy += doc.heightOfString(ticket.event.campus.name, { width: innerW }) + 2;
}
// Ticket type
const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : '';
const type = `${ticket.registrationOption.eventOption.name}${variantSuffix}`;
@@ -983,8 +989,9 @@ async function emailTicketsInternal(req, res, userOverride) {
return res.json({ message: 'No valid contact details on file — ticket delivery skipped.' });
}
const showCampus = await require('../utils/campuses').campusesEnabled();
const ticketInclude = {
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } },
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } }, ...(showCampus ? { campus: { select: { name: true } } } : {}) } },
registrationOption: { include: { eventOption: true, variant: true, session: true, registration: { select: { ticketDelivery: true } } } },
attendee: { include: { allocations: { where: { set: { showOnTicket: true } }, include: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } } } },
user: { select: { name: true } },
+15 -1
View File
@@ -537,6 +537,7 @@ const getUserProfile = async (req, res) => {
createdAt: true,
updatedAt: true,
isActive: true,
homeCampusId: true,
newsAskedAt: true,
noticeVersionAccepted: true
}
@@ -571,7 +572,18 @@ const updateUserProfile = async (req, res) => {
throw new Error('User not found');
}
const { name, email, password, phoneNumber, currentPassword, notificationPreference } = req.body || {};
const { name, email, password, phoneNumber, currentPassword, notificationPreference, homeCampusId } = req.body || {};
// Home campus (only while campuses are on): an active campus, or ''/null for none.
let newHomeCampusId = user.homeCampusId;
if (homeCampusId !== undefined && await require('../utils/campuses').campusesEnabled()) {
if (!homeCampusId) newHomeCampusId = null;
else {
const campus = await prisma.campus.findFirst({ where: { id: String(homeCampusId), archivedAt: null }, select: { id: true } });
if (!campus) { res.status(400); throw new Error('Choose one of the campuses in the list.'); }
newHomeCampusId = campus.id;
}
}
// If a password change is requested, verify current password for safety
let newHashedPassword = undefined;
@@ -619,6 +631,7 @@ const updateUserProfile = async (req, res) => {
phoneNumber: newPhone,
notificationPreference: newPref,
twoFactorMethod: newTwoFactorMethod,
homeCampusId: newHomeCampusId,
updatedAt: new Date()
},
select: {
@@ -633,6 +646,7 @@ const updateUserProfile = async (req, res) => {
createdAt: true,
updatedAt: true,
isActive: true,
homeCampusId: true,
tokenVersion: true
}
});
+61 -17
View File
@@ -1,8 +1,10 @@
/**
* Talking to the Hope Events Hub (docs/hub-protocol.md): register with a licence key, then a
* signed heartbeat every 15 minutes. Install → Hub only. Stored in AppSetting:
* Talking to the Cross Events Hub (docs/hub-protocol.md): register with a licence key, then a
* signed heartbeat every 15 minutes, and a signed update request when an admin asks for one.
* Install → Hub only. Stored in AppSetting:
* hub_install_id, hub_install_secret (encrypted), hub_licence (signed token),
* hub_last_heartbeat_at, hub_last_error, hub_latest_version.
* hub_last_heartbeat_at, hub_last_error, hub_latest_version,
* hub_update_requested_at, hub_update_requested_version.
* These keys are kept out of the generic settings endpoints (settingsController).
*/
@@ -14,7 +16,7 @@ const report = require('./report');
const PROTOCOL_VERSION = 1;
const TIMEOUT_MS = 15000;
const HUB_KEYS = ['hub_install_id', 'hub_install_secret', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version'];
const HUB_KEYS = ['hub_install_id', 'hub_install_secret', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version'];
function hubUrl() {
return String(process.env.HUB_URL || 'https://events-hub.crosscode.co.za').replace(/\/$/, '');
@@ -59,10 +61,25 @@ async function post(path, body, headers = {}) {
return data;
}
/** POST signed with the install secret (§3). Throws NOT_CONNECTED when there is no connection. */
async function signedPost(path, body) {
const s = await read(['hub_install_id', 'hub_install_secret']);
if (!s.hub_install_id || !s.hub_install_secret) throw new HubError('This site isn\'t connected to the Hub yet.', 'NOT_CONNECTED', 400);
const secret = decrypt(s.hub_install_secret);
return post(path, body, raw => {
const timestamp = new Date().toISOString();
return {
'X-Hub-Install': s.hub_install_id,
'X-Hub-Timestamp': timestamp,
'X-Hub-Signature': crypto.createHmac('sha256', secret).update(`${timestamp}.${raw}`).digest('hex'),
};
});
}
/** Register this install with a licence key. Returns the licence claims. */
async function connect(licenceKey, adminContact) {
if (!require('./publicKey').hubPublicKey()) {
throw new HubError('This version of Hope Events has no Hub key built in, so it can\'t check licences yet.', 'NO_PUBLIC_KEY', 500);
throw new HubError('This version of Cross Events has no Hub key built in, so it can\'t check licences yet.', 'NO_PUBLIC_KEY', 500);
}
const install = await report.installInfo();
if (!/^https?:\/\//.test(install.url)) {
@@ -92,17 +109,9 @@ async function connect(licenceKey, adminContact) {
async function heartbeat() {
const s = await read(['hub_install_id', 'hub_install_secret']);
if (!s.hub_install_id || !s.hub_install_secret) return { skipped: 'not connected' };
const secret = decrypt(s.hub_install_secret);
const body = { protocolVersion: PROTOCOL_VERSION, version: report.APP_VERSION, health: await report.health(), stats: await report.stats() };
try {
const data = await post('/api/installs/heartbeat', body, raw => {
const timestamp = new Date().toISOString();
return {
'X-Hub-Install': s.hub_install_id,
'X-Hub-Timestamp': timestamp,
'X-Hub-Signature': crypto.createHmac('sha256', secret).update(`${timestamp}.${raw}`).digest('hex'),
};
});
const data = await signedPost('/api/installs/heartbeat', body);
const claims = verifyToken(data.licence);
await save({
// Keep the old token if the new one doesn't check out (never accept an unverified one).
@@ -114,10 +123,45 @@ async function heartbeat() {
return { ok: true, status: claims?.status || null };
} catch (e) {
await save({ hub_last_error: `${new Date().toISOString()} ${e.code || ''} ${e.message}`.slice(0, 500) });
// The Hub doesn't know this install any more (licence released): show "Reconnect".
if (e.code === 'INSTALL_NOT_FOUND') return { ok: false, reconnect: true };
// The Hub doesn't know this install any more (licence released): drop the connection and the
// licence, so the site is unlicensed (read-only) and shows "Connect" until a key is entered.
if (e.code === 'INSTALL_NOT_FOUND') {
await save({ hub_install_id: null, hub_install_secret: null, hub_licence: null });
return { ok: false, reconnect: true };
}
throw e;
}
}
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat };
/**
* Ask the Hub for this site to be updated (§3a). Joshua is told; the site never updates itself.
* Returns { requestedAt, version, alreadyRequested }.
*/
async function requestUpdate() {
let data;
try {
data = await signedPost('/api/installs/update-request', { protocolVersion: PROTOCOL_VERSION, version: report.APP_VERSION });
} catch (e) {
if (e.code === 'NOT_FOUND') throw new HubError('The Hub doesn\'t take update requests yet. Please contact your provider.', 'NOT_SUPPORTED', 502);
throw e;
}
await save({ hub_update_requested_at: data.requestedAt, hub_update_requested_version: data.version || null });
return data;
}
/**
* A heartbeat soon (on startup, and after connecting), so a licence change on the Hub shows
* without waiting for the next 15-minute run. Never throws; skipped when the job is switched off.
*/
let soonTimer = null;
function heartbeatSoon(delayMs = 30 * 1000) {
if (String(process.env.JOBS_ENABLED || 'true').toLowerCase() === 'false') return;
if (String(process.env.HUB_HEARTBEAT_ENABLED || 'true').toLowerCase() === 'false') return;
clearTimeout(soonTimer);
soonTimer = setTimeout(() => {
heartbeat().catch(e => console.warn('[hub] Heartbeat failed:', e?.message || e));
}, delayMs);
soonTimer.unref?.();
}
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat, heartbeatSoon, requestUpdate };
+14 -4
View File
@@ -7,7 +7,10 @@
* suspended — suspended by the Hub, or grace ran out: read-only (no new events,
* registrations or payments); tickets still scan, data can be exported
* revoked — like suspended, with a permanent banner
* unlicensed — never connected to the Hub: works, admins see "Connect to Hub"
* unlicensed — not connected to the Hub (never, or the Hub released this install): read-only,
* like suspended, until a licence key is connected
*
* LICENCE_CHECK_DISABLED=true turns read-only off outside production (the end-to-end tests).
*/
const crypto = require('crypto');
@@ -15,7 +18,7 @@ const prisma = require('../config/db');
const { hubPublicKey } = require('./publicKey');
const DAY = 24 * 60 * 60 * 1000;
const READ_ONLY_STATES = new Set(['suspended', 'revoked']);
const READ_ONLY_STATES = new Set(['suspended', 'revoked', 'unlicensed']);
const CACHE_MS = 30 * 1000;
let publicKeyObject = null;
@@ -53,7 +56,7 @@ function verifyToken(token, key = publicKey()) {
*/
function stateFor(claims, installId, now = new Date()) {
if (!claims || (installId && claims.installId !== installId)) {
return { state: 'unlicensed', readOnly: false, endsAt: null, graceEndsAt: null, daysLeft: null };
return { state: 'unlicensed', readOnly: true, endsAt: null, graceEndsAt: null, daysLeft: null };
}
const ends = [claims.licenceExpiresAt, claims.validUntil].filter(Boolean).map(d => new Date(d));
const endsAt = ends.length ? new Date(Math.min(...ends)) : null;
@@ -68,12 +71,16 @@ function stateFor(claims, installId, now = new Date()) {
return { state, readOnly: READ_ONLY_STATES.has(state), endsAt, graceEndsAt, daysLeft, expired: state === 'suspended' && claims.status === 'active' };
}
function checkDisabled() {
return process.env.NODE_ENV !== 'production' && String(process.env.LICENCE_CHECK_DISABLED || '').toLowerCase() === 'true';
}
let cache = null;
/** This install's licence: claims, state and Hub connection details. Cached for 30 s. */
async function licenceStatus({ fresh = false } = {}) {
if (!fresh && cache && Date.now() - cache.at < CACHE_MS) return cache.value;
const rows = await prisma.appSetting.findMany({ where: { key: { in: ['hub_install_id', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version'] } } });
const rows = await prisma.appSetting.findMany({ where: { key: { in: ['hub_install_id', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version'] } } });
const s = Object.fromEntries(rows.map(r => [r.key, r.value]));
const claims = verifyToken(s.hub_licence);
const value = {
@@ -82,9 +89,12 @@ async function licenceStatus({ fresh = false } = {}) {
installId: s.hub_install_id || null,
claims: claims && claims.installId === s.hub_install_id ? claims : null,
...stateFor(claims, s.hub_install_id || null),
...(checkDisabled() ? { readOnly: false } : {}),
lastHeartbeatAt: s.hub_last_heartbeat_at || null,
lastError: s.hub_last_error || null,
latestVersion: s.hub_latest_version || null,
updateRequestedAt: s.hub_update_requested_at || null,
updateRequestedVersion: s.hub_update_requested_version || null,
};
cache = { at: Date.now(), value };
return value;
+5 -5
View File
@@ -1,13 +1,13 @@
/**
* The Hope Events Hub's public licence key (Ed25519). Installs check every licence token with
* The Cross Events Hub's public licence key (Ed25519). Installs check every licence token with
* 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 };
+10 -4
View File
@@ -1,6 +1,7 @@
/**
* Read-only mode while the licence is suspended or revoked: no *new* activity — new events,
* registrations, tickets added, payments started or recorded, waitlist places, coupons or bundles.
* Read-only mode while the licence is suspended, revoked or missing (not connected to the Hub):
* no new or edited events, and no *new* activity — registrations, tickets added, payments started
* or recorded, waitlist places, coupons or bundles.
* Everything else keeps working on purpose: scanning tickets for events people already paid for,
* viewing and exporting data, refunds and cancellations, privacy requests, logging in, and the
* settings needed to renew the licence. Payment webhooks for payments already started also still
@@ -11,8 +12,11 @@ const { licenceStatus } = require('./licence');
const BLOCKED = [
['POST', /^\/api\/events\/?$/],
['PUT', /^\/api\/events\/[^/]+\/?$/],
['POST', /^\/api\/events\/[^/]+\/options\/?$/],
['PUT', /^\/api\/events\/options\/[^/]+\/?$/],
['POST', /^\/api\/events\/options\/[^/]+\/variants\/?$/],
['PUT', /^\/api\/events\/variants\/[^/]+\/?$/],
['POST', /^\/api\/registrations\/?$/],
['POST', /^\/api\/registrations\/manual\/?$/],
['PUT', /^\/api\/registrations\/[^/]+\/options\/?$/],
@@ -36,8 +40,10 @@ async function licenceReadOnly(req, res, next) {
return res.status(403).json({
code: 'LICENCE_READ_ONLY',
message: status.state === 'revoked'
? "This site's licence has been revoked, so nothing new can be added. Tickets still scan and all data can still be viewed and exported."
: "This site's licence is suspended, so nothing new can be added for now. Tickets still scan and all data can still be viewed and exported. Please contact the site's administrator.",
? "This site's licence has been revoked, so nothing new can be added and events can't be changed. Tickets still scan and all data can still be viewed and exported."
: status.state === 'unlicensed'
? "This site has no licence, so nothing new can be added and events can't be changed. A site admin can connect a licence key in Site settings → Licence."
: "This site's licence is suspended, so nothing new can be added and events can't be changed for now. Tickets still scan and all data can still be viewed and exported. Please contact the site's administrator.",
});
} catch {
// Never let a licence check failure take the site down.
+9 -4
View File
@@ -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,19 +133,20 @@ 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 || 'Hope Events',
name: name || 'Cross Events',
url: String(url || process.env.FRONTEND_URL || '').split(',')[0].trim().replace(/\/$/, ''),
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 },
};
}
+25 -3
View File
@@ -162,7 +162,7 @@ app.use((req, res, next) => {
next();
});
// Licence suspended or revoked: no new events, registrations or payments (src/hub/readOnly.js).
// Licence suspended, revoked or missing: no new or edited events, registrations or payments (src/hub/readOnly.js).
app.use(require('./hub/readOnly').licenceReadOnly);
// Health for monitoring (and the Hub): no secrets, no personal information.
@@ -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);
@@ -916,11 +917,29 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Healthy', body:{ status:'ok', version:'1.41.0', db:true, jobs:{ mode:'queue', failedLast24h:0 }, lastBackupAt:'2026-10-26T00:00:12.000Z' }}]},
{ method:'GET', path:'/api/hub/status', auth:'admin', desc:'Licence state (active, grace, suspended, revoked, unlicensed), Hub connection, version and update',
responses:[{ status:200, desc:'Success', body:{ state:'grace', readOnly:false, daysLeft:9, connected:true, version:'1.41.0', latestVersion:'1.42.0', updateAvailable:true }}]},
{ method:'POST', path:'/api/hub/connect', auth:'admin', desc:'Connect to the Hope Events Hub with a licence key (also the /setup licence step)',
{ method:'POST', path:'/api/hub/connect', auth:'admin', desc:'Connect to the Cross Events Hub with a licence key (also the /setup licence step)',
request:{ body:{ licenceKey:'CEH-7K2M-9QXA-P4RT-WD8C' } },
responses:[{ status:200, desc:'Connected (status as above)' }, { status:404, desc:'Unknown key', body:{ code:'LICENCE_NOT_FOUND', message:'That licence key was not found.' }}]},
{ method:'POST', path:'/api/hub/heartbeat', auth:'admin', desc:'Check in with the Hub now (picks up licence changes)',
responses:[{ status:200, desc:'Status' }, { status:502, desc:'Hub unreachable' }]},
{ method:'POST', path:'/api/hub/update-request', auth:'admin', desc:'Ask the Hub for this site to be updated to the latest version (the provider is told; the site never updates itself)',
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: [
@@ -1169,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' },
@@ -1570,6 +1589,9 @@ app.listen(PORT, () => {
// backup, temp cleanup) run on the pg-boss job queue — see src/jobs/index.js.
require('./jobs').startJobs().catch(e => console.warn('[jobs] Not started:', e?.message || e));
// Check in with the Hub 30 s after starting, so the licence is fresh without waiting 15 minutes.
require('./hub/client').heartbeatSoon();
// Scheduled emails worker (polling)
try {
const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false';
+8 -2
View File
@@ -57,7 +57,7 @@ const JOBS = [
run: () => require('../utils/privacy').remindDueRequests(new Date()),
},
{
name: 'hub-heartbeat', label: 'Hope Events Hub: heartbeat and licence refresh', cron: '*/15 * * * *', env: 'HUB_HEARTBEAT_ENABLED',
name: 'hub-heartbeat', label: 'Cross Events Hub: heartbeat and licence refresh', cron: '*/15 * * * *', env: 'HUB_HEARTBEAT_ENABLED',
run: () => require('../hub/client').heartbeat(),
},
{
@@ -247,8 +247,14 @@ async function runNow(name) {
async function jobStatus(prisma) {
const recent = new Map();
if (mode === 'queue') {
// pg-boss columns are timestamptz, and Prisma's pg adapter drops their offset in raw queries
// (10:55+02 came back as 10:55Z). As plain UTC timestamps they come back right.
const rows = await prisma.$queryRawUnsafe(`
SELECT name, state, created_on AS "createdOn", started_on AS "startedOn", completed_on AS "completedOn", output
SELECT name, state,
created_on AT TIME ZONE 'UTC' AS "createdOn",
started_on AT TIME ZONE 'UTC' AS "startedOn",
completed_on AT TIME ZONE 'UTC' AS "completedOn",
output
FROM (
SELECT name, state, created_on, started_on, completed_on, output,
row_number() OVER (PARTITION BY name ORDER BY created_on DESC) AS rn
+20
View File
@@ -0,0 +1,20 @@
const express = require('express');
const router = express.Router();
const {
getCampuses, getCampusBySlug, getCampusesAdmin, updateCampusSettings, createCampus, updateCampus, reorderCampuses, deleteCampus,
getCampusAudience,
} = require('../controllers/campusController');
const { protect, admin, supervisor } = require('../middleware/authMiddleware');
// Multi-campus (utils/campuses.js). The public list is empty while campuses are off.
router.get('/', getCampuses);
router.get('/by-slug/:slug', getCampusBySlug);
router.get('/admin', protect, admin, getCampusesAdmin);
router.put('/settings', protect, admin, updateCampusSettings);
router.put('/order', protect, admin, reorderCampuses);
router.post('/', protect, admin, createCampus);
router.put('/:id', protect, admin, updateCampus);
router.delete('/:id', protect, admin, deleteCampus);
router.get('/:id/audience', protect, supervisor, getCampusAudience);
module.exports = router;
+3 -2
View File
@@ -1,12 +1,13 @@
const express = require('express');
const router = express.Router();
const { getStatus, connect, heartbeatNow } = require('../controllers/hubController');
const { getStatus, connect, heartbeatNow, requestUpdate } = require('../controllers/hubController');
const { protect, admin } = require('../middleware/authMiddleware');
// Licence and the Hope Events Hub (docs/hub-protocol.md). Admins only; the setup wizard's
// Licence and the Cross Events Hub (docs/hub-protocol.md). Admins only; the setup wizard's
// admin token counts, so the licence step can run during /setup.
router.get('/status', protect, admin, getStatus);
router.post('/connect', protect, admin, connect);
router.post('/heartbeat', protect, admin, heartbeatNow);
router.post('/update-request', protect, admin, requestUpdate);
module.exports = router;
+138
View File
@@ -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,
};
+8 -1
View File
@@ -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) {
+1
View File
@@ -404,6 +404,7 @@ async function anonymiseUser(userId, db = prisma) {
tokenVersion: { increment: 1 },
newsEmail: false,
newsWhatsApp: false,
homeCampusId: null,
},
});
+1
View File
@@ -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.',
+80
View File
@@ -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');
});
});
+27 -3
View File
@@ -4,7 +4,7 @@ const crypto = require('crypto');
const { verifyToken, stateFor } = require('../src/hub/licence');
const { incomeBand } = require('../src/hub/report');
const { isBlocked } = require('../src/hub/readOnly');
const { compareVersions } = require('../src/controllers/hubController');
const { compareVersions, pendingUpdateRequest } = require('../src/controllers/hubController');
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
function sign(payload, key = privateKey) {
@@ -33,8 +33,8 @@ describe('licence tokens', () => {
});
describe('licence states', () => {
test('never connected → unlicensed, not read-only', () => {
expect(stateFor(null, null, NOW)).toMatchObject({ state: 'unlicensed', readOnly: false });
test('never connected → unlicensed, read-only like suspended', () => {
expect(stateFor(null, null, NOW)).toMatchObject({ state: 'unlicensed', readOnly: true });
});
test('a token for another install counts as none', () => {
@@ -80,6 +80,9 @@ describe('heartbeat report', () => {
describe('read-only mode', () => {
test.each([
['POST', '/api/events'],
['PUT', '/api/events/ev_1'],
['PUT', '/api/events/options/opt_1'],
['PUT', '/api/events/variants/var_1'],
['POST', '/api/registrations'],
['POST', '/api/registrations/manual'],
['PUT', '/api/registrations/abc/options'],
@@ -92,6 +95,7 @@ describe('read-only mode', () => {
test.each([
['POST', '/api/tickets/scan/QR123'],
['GET', '/api/events'],
['PUT', '/api/events/ev_1/notify-recipients'],
['PUT', '/api/registrations/abc'],
['POST', '/api/payments/refund'],
['POST', '/api/privacy/me/requests'],
@@ -120,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"', () => {
@@ -127,3 +143,11 @@ test('version comparison for "update available"', () => {
expect(compareVersions('1.41.0', '1.41.0')).toBe(0);
expect(compareVersions('2.0.0', '1.50.3')).toBeGreaterThan(0);
});
test('an update request shows until the site runs the version asked for', () => {
const at = '2026-10-27T09:00:00.000Z';
expect(pendingUpdateRequest('1.41.2', at, '1.42.0')).toEqual({ requestedAt: at, version: '1.42.0' });
expect(pendingUpdateRequest('1.42.0', at, '1.42.0')).toBeNull();
expect(pendingUpdateRequest('1.43.0', at, '1.42.0')).toBeNull();
expect(pendingUpdateRequest('1.41.2', null, null)).toBeNull();
});
+1 -1
View File
@@ -1,4 +1,4 @@
# Hope Events — New Features
# Cross Events — New Features
Planned features for the next round of development. Each section covers what
the feature does, who uses it, how it fits the existing platform, and the edge
+57 -19
View File
@@ -1,14 +1,26 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-26, after **1.41.0** (and Hub 1.0.0). The plan itself is in
Last updated: 2026-10-27, after **1.43.0** (and Hub 1.1.0). The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
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.41.0 | Hope 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 |
| 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 |
| 1.40.0 | POPIA toolkit part 3 — readiness checklist with score, retention policies + nightly `retention` job (inactive accounts anonymised after 5 years with a 30-day warning, on by default), breach register with notification drafts, children's/medical flag in the access log, `{retention}` in the notice |
| 1.39.0 | POPIA toolkit part 2 — Admin → Privacy: privacy notice editor (built-in text as the start, placeholders, drafts, numbered versions, re-acceptance after login), operator register, consent log, access log; Information Officer on the contact page |
| 1.38.0 | POPIA toolkit part 1 — Privacy page (news opt-in + history, download my data, correction and deletion requests), Event update vs News & announcements on every bulk send, unsubscribe links + one-click headers, WhatsApp STOP, one-time news question, Admin → Data requests, thorough anonymising |
@@ -26,13 +38,15 @@ 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.44.0 — Campus-scoped staff** (§12, "Plumbing (1.44.0)"): `UserCampusScope`, a
`utils/campusScope.js` helper on every staff/supervisor endpoint (115 in 27 route files at
1.42.1), and the route-list test that fails on any endpoint that isn't campus-checked or marked
campus-independent.
## Working on this repo
@@ -156,24 +170,48 @@ 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" route there).
- 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).
"create" or event-edit route there). Read-only = suspended, revoked or unlicensed.
- 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
Hope Events with `HUB_URL=http://localhost:5200` and `HUB_PUBLIC_KEY` = the key on Hub → Settings.
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.
## Known gaps / follow-ups
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
+83 -40
View File
@@ -1,6 +1,6 @@
# Hope Events — Road to 2.0.0
# Cross Events — Road to 2.0.0
Planned features and platform work to take Hope Events from 1.32.x to 2.0.0.
Planned features and platform work to take Cross Events from 1.32.x to 2.0.0.
Each section covers what the feature does, who uses it, how it fits the
existing platform, and the edge cases to handle. See [FEATURES.md](../FEATURES.md)
for what already exists, and [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md) for the
@@ -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,14 @@ 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).
**Next: 1.44.0 Campus-scoped staff.**
**Repositories:**
@@ -48,7 +54,7 @@ notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (
10. [Languages (Afrikaans first)](#10-languages-afrikaans-first)
11. [Accessibility pass (WCAG 2.2 AA)](#11-accessibility-pass-wcag-22-aa)
12. [Multi-campus (one install per church)](#12-multi-campus-one-install-per-church)
13. [Central management platform (Hope Events Hub)](#13-central-management-platform-hope-events-hub)
13. [Central management platform (Cross Events Hub)](#13-central-management-platform-cross-events-hub)
14. [Foundations (platform work)](#14-foundations-platform-work)
15. [AI helpers (2.0 headline)](#15-ai-helpers-20-headline)
16. [AI: later stages](#16-ai-later-stages)
@@ -464,7 +470,7 @@ Plan who serves where, for an event or each session.
## 8. Outbound webhooks
Let other systems react to what happens in Hope Events.
Let other systems react to what happens in Cross Events.
- Admin adds endpoints and picks events: `registration.created`,
`registration.paid`, `registration.cancelled`, `payment.refunded`,
@@ -517,7 +523,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 +542,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 +569,42 @@ 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).
### 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
@@ -580,13 +617,13 @@ Because every church is a separate install, a few tools make that manageable:
migrations, build, restart; roll back on failure.
- **Version and health:** current version shown in admin and on a `/health`
endpoint (version, DB, jobs, last backup), reported to the
[Hub](#13-central-management-platform-hope-events-hub).
[Hub](#13-central-management-platform-cross-events-hub).
---
## 13. Central management platform (Hope Events Hub)
## 13. Central management platform (Cross Events Hub)
A separate app, run by Joshua, to see and manage **every Hope Events
A separate app, run by Joshua, to see and manage **every Cross Events
install** from one place: who the customers are, which version each runs,
whether they're healthy, basic usage stats, and their **licence** status.
@@ -601,11 +638,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.
- Hope Events repo: the Hub **client** — licence step in `/setup`,
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 +698,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 +734,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 +747,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 +909,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/`),
@@ -896,7 +936,7 @@ feature.
| 6 | Selling model | **One install per church** — data separation, any server. No multi-tenant. **Multi-campus** inside an install is in scope |
| — | Pro-rata rounding | Nearest R10 |
| — | Multi-campus | In scope (§12), inside one install |
| — | Central management | **Hope Events Hub** in its **own git repo** (`joshua/events-hub`): installs register on setup, send heartbeats (health, version, aggregate stats), licences switched on/off centrally |
| — | Central management | **Cross Events Hub** in its **own git repo** (`joshua/events-hub`): installs register on setup, send heartbeats (health, version, aggregate stats), licences switched on/off centrally |
| 7 | Payment provider order | Yoco · PayFast. Joshua can supply documentation links |
| — | SnapScan, Zapper | Dropped (2026-10-23): Yoco and PayFast are enough. Later versions moved up by two |
| — | Provider fees | Not deducted or estimated automatically; handled by people during cashup. No "pass fee to payer" |
@@ -920,6 +960,9 @@ 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) |
| — | 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
+27 -5
View File
@@ -1,6 +1,6 @@
# Hub protocol (version 1)
The contract between a **Hope Events install** (the church's site) and the **Hope Events
The contract between a **Cross Events install** (the church's site) and the **Cross Events
Hub** (`events-hub.crosscode.co.za`). A copy of this file lives in both repositories
(`joshua/hope-events` → `docs/hub-protocol.md`, `joshua/events-hub` → `docs/hub-protocol.md`);
change both together.
@@ -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" }
}
}
@@ -110,6 +111,27 @@ X-Hub-Signature: <hex HMAC-SHA256(installSecret, timestamp + "." + rawBody)>
`R1 001–R5 000`, `R5 001–R20 000`, `R20 001–R50 000`, `R50 001–R100 000`, `over R100 000`.
- The Hub keeps every heartbeat for 30 days, then one per install per day.
## 3a. Update request
Sent when a site admin presses **Request update** (Site settings → Licence). Installs never update
themselves and never show the deploy command; the request tells Joshua, who updates the server.
Signed exactly like the heartbeat.
```
POST /api/installs/update-request
X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature (as in §3)
{ "protocolVersion": 1, "version": "1.41.2" }
→ 201 { "requestedAt": "2026-10-27T09:00:00.000Z", "version": "1.42.0", "alreadyRequested": false }
→ 200 { …, "alreadyRequested": true } // a request is already open: not emailed again
```
- The Hub opens an `update_requested` alert on the install (shown under Alerts) and emails the
alert address once. It is resolved when a heartbeat reports a newer version than before.
- `409 UP_TO_DATE` — the install already runs the Hub's latest version (or none is set).
- Errors as in §3 (`INSTALL_NOT_FOUND`, `STALE_REQUEST`, `BAD_SIGNATURE`). Rate-limited (10 an
hour per IP). A Hub older than this section answers `404 NOT_FOUND`.
- No personal information: not who pressed the button (the Hub already has the admin contact).
## 4. Licence token
`hel1.<payload>.<signature>` — `payload` is base64url JSON, `signature` is the base64url
@@ -140,9 +162,9 @@ out its state:
|-------|------|--------------|
| **active** | `status` is active and now is before `min(licenceExpiresAt, validUntil)` | Everything works |
| **grace** | after that date, for up to `graceDays` | Everything works; **only site admins** see a banner "Licence needs renewing — X days left" |
| **suspended** | `status` is suspended, or grace has run out | Read-only: no new events, registrations or payments. Tickets still scan; admins can view and export all data; privacy requests still work |
| **suspended** | `status` is suspended, or grace has run out | Read-only: events can't be created or edited; no new registrations or payments. Tickets still scan; admins can view and export all data; privacy requests still work |
| **revoked** | `status` is revoked | Same as suspended, with a permanent banner |
| **unlicensed** | never connected | Works; admins see "Connect to Hub" |
| **unlicensed** | never connected, or the Hub answered `INSTALL_NOT_FOUND` (the install drops its token) | Same as suspended (events can't be created or edited either); admins see "Connect to Hub" |
`validUntil` is refreshed by every heartbeat, so a Hub outage only matters after 30 days (then
grace starts). Blocking the Hub can't keep a suspended licence active for more than 30 days.
@@ -156,5 +178,5 @@ grace starts). Blocking the Hub can't keep a suspended licence active for more t
## 6. Later (not in v1)
- **AI proxy** (Hope Events 2.0): `POST /api/ai/complete`, signed like the heartbeat; the Hub holds
- **AI proxy** (Cross Events 2.0): `POST /api/ai/complete`, signed like the heartbeat; the Hub holds
the Claude API key, meters each call in rand and enforces `limits.aiMonthlyCapCents`.
+45
View File
@@ -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");
});
});
+1
View File
@@ -6,6 +6,7 @@ export type Role = "admin" | "supervisor" | "staff" | "user";
export type Seed = {
users: Record<Role, { id: string; name: string; email: string; token: string }>;
events: { free: string; paid: string; course: string; dropIn: string; conference: string };
campuses: Record<"north" | "south", { id: string; slug: string }>;
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
unsubscribeToken: string;
newsPromptUser: { id: string; token: string };
+8
View File
@@ -68,8 +68,14 @@ async function seed(url) {
return d;
};
// Multi-campus: two campuses with campuses switched off (campuses.spec.ts turns them on).
// Seeded here rather than in the test so pages built before the tests already carry them.
const north = await prisma.campus.create({ data: { name: 'E2E North', slug: 'e2e-north', address: '1 North Road', order: 0 } });
const south = await prisma.campus.create({ data: { name: 'E2E South', slug: 'e2e-south', order: 1 } });
const free = await prisma.event.create({
data: {
campusId: north.id,
title: 'E2E Free Evening', description: 'A free evening for the tests.', startDate: at(1), endDate: at(1, 19),
price: 0, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
eventOptions: { create: [{ name: 'Entry', price: 0, isMainTicket: true }] },
@@ -77,6 +83,7 @@ async function seed(url) {
});
const paid = await prisma.event.create({
data: {
campusId: south.id,
title: 'E2E Paid Conference', description: 'A paid one-day conference.', startDate: at(3, 7), endDate: at(3, 15),
price: 150, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
eventOptions: { create: [{ name: 'Conference ticket', price: 150, isMainTicket: true }] },
@@ -156,6 +163,7 @@ async function seed(url) {
const out = {
users: Object.fromEntries(Object.entries(users).map(([k, u]) => [k, { id: u.id, name: u.name, email: u.email, token: generateToken(u.id, u.role, u.tokenVersion || 0) }])),
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id, conference: conference.id },
campuses: { north: { id: north.id, slug: north.slug }, south: { id: south.id, slug: south.slug } },
course: { sessions: course.sessions.map(s => ({ id: s.id, label: s.label })), ticketId: ticket.id, qrCode: ticket.qrCode },
// An account that has never answered the news question (asked once after login).
newsPromptUser: await (async () => {
+2 -2
View File
@@ -1,6 +1,6 @@
# Hope Events — Frontend
# Cross Events — Frontend
Next.js 15 (App Router) frontend for the Hope Events platform used by Hope Family Church (`events.hopehenley.co.za`).
Next.js 15 (App Router) frontend for the Cross Events platform used by Hope Family Church (`events.hopehenley.co.za`).
---
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.41.0",
"version": "1.43.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.41.0",
"version": "1.43.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.41.0",
"version": "1.43.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
+93
View File
@@ -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>
);
}
@@ -95,7 +95,14 @@ const ACTION_LABELS: Record<string, string> = {
retention_run: "Retention job deleted/anonymised data",
breach_recorded: "Security compromise recorded",
breach_updated: "Security compromise updated",
hub_connected: "Connected to the Hope Events Hub",
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 {
+6 -2
View File
@@ -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&apos;s what&apos;s happening with your events today.</p>
<CampusFilter value={campus} onChange={setCampus} everyLabel="All campuses (church-wide total)" className="mt-3" id="dashboard-campus" />
</div>
{!isAdmin && (
@@ -7,22 +7,24 @@ import Link from "next/link";
import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, KeyRound, type LucideIcon } from "lucide-react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, KeyRound, MapPinned, type LucideIcon } from "lucide-react";
import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { BackupsTab } from "@/components/admin/BackupsTab";
import { JobsTab } from "@/components/admin/JobsTab";
import { PaymentsTab } from "@/components/admin/PaymentsTab";
import { LicenceTab } from "@/components/admin/LicenceTab";
import { CampusesTab } from "@/components/admin/CampusesTab";
import { extractDominantColors } from "@/lib/extractColors";
import { mapsSearchUrl } from "@/lib/maps";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "backups" | "jobs" | "licence";
type TabId = "organisation" | "campuses" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "backups" | "jobs" | "licence";
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "organisation", label: "Organisation", icon: Building2 },
{ id: "campuses", label: "Campuses", icon: MapPinned },
{ id: "branding", label: "Branding", icon: Palette },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "email", label: "Email", icon: Mail },
@@ -716,6 +718,7 @@ function SiteSettingsPageInner() {
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
{activeTab === "jobs" && <JobsTab active={activeTab === "jobs"} />}
{activeTab === "licence" && <LicenceTab active={activeTab === "licence"} />}
{activeTab === "campuses" && <CampusesTab active={activeTab === "campuses"} />}
</div>
{activeTab === "branding" && (
@@ -11,6 +11,7 @@ import { errorMessage } from "@/lib/utils";
import type { Event, MessageSendResult, Registration, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { MessageKindPicker, matchedText, type MessageKind } from "@/components/shared/MessageKindPicker";
import { CampusAudiencePicker } from "@/components/shared/CampusAudiencePicker";
type Attendee = { id: string; name: string; email: string; pref: string };
type TemplateKey = 'custom'|'payment_reminder'|'event_reminder'|'tickets';
@@ -735,6 +736,7 @@ function EmailAttendeesPageInner() {
channel="email"
/>
<div className="text-[11px] text-gray-500 mb-1">Select users from your database to include. Green = Email/both preference. You can also add external emails below.</div>
<CampusAudiencePicker selectedIds={selectedUserIds} onChange={setSelectedUserIds} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Event (optional for placeholders)</label>
@@ -8,6 +8,8 @@ import { apiFetch, API_BASE } from "@/lib/api";
import { ApiImage } from "@/components/shared/ApiImage";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useCampuses } from "@/hooks/useCampuses";
import { CampusBadge, CampusFilter, matchesCampus } from "@/components/shared/CampusFilter";
import { Calendar } from "lucide-react";
import { ChoicesEditor } from "@/components/shared/FormFieldExtras";
import { errorMessage } from "@/lib/utils";
@@ -830,6 +832,7 @@ interface EventDraft {
redirectUrl: string; isActive: boolean; isHidden: boolean; requiresAuth: boolean;
requiresRegistration: boolean; contactName: string; contactPhone: string; contactEmail: string;
location: string; allowNamedAttendees: boolean;
campusId: string; // "" = all campuses
requiresParentalConsent: boolean; consentText: string;
consentCollectMedical: boolean; consentCollectEmergency: boolean; consentCollectPhoto: boolean;
waitlistEnabled: boolean; waitlistOfferHours: string; waitlistPartialOffers: boolean;
@@ -845,6 +848,7 @@ const blankDraft = (location = ""): EventDraft => ({
redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true,
requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "",
location, allowNamedAttendees: false,
campusId: "",
requiresParentalConsent: false, consentText: "",
consentCollectMedical: true, consentCollectEmergency: true, consentCollectPhoto: true,
waitlistEnabled: false, waitlistOfferHours: "24", waitlistPartialOffers: true,
@@ -873,6 +877,7 @@ interface EventModalProps {
function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const { token } = useAuth();
const { settings } = useSiteSettings();
const campusInfo = useCampuses();
const [step, setStep] = useState<StepIdx>(0);
const [pricingSubstep, setPricingSubstep] = useState<PricingSubstep>(0);
const [saving, setSaving] = useState(false);
@@ -897,6 +902,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
allowSponsorships: !!ev.allowSponsorships, sponsorshipGoalSpots: ev.sponsorshipGoalSpots ? String(ev.sponsorshipGoalSpots) : "", showSponsorshipProgress: ev.showSponsorshipProgress !== false,
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
location: ev.location || "",
campusId: ev.campusId || "",
scheduleType: ev.scheduleType === "sessions" ? "sessions" : "single",
attendanceMode: ev.attendanceMode === "pick" ? "pick" : "whole",
} : blankDraft(settings.org_address || ""));
@@ -1284,6 +1290,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
contactPhone: draft.contactPhone || undefined,
contactEmail: draft.contactEmail || undefined,
location: draft.location.trim(),
// Only sent while campuses are on; the server ignores it otherwise.
...(campusInfo.enabled ? { campusId: draft.campusId || null } : {}),
};
let notice: { eventId: string; moved: number; cancelled: number } | null = null;
@@ -1473,6 +1481,29 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
<DTInput label="Registration Deadline (optional)" value={draft.registrationDeadline} onChange={v => upd({ registrationDeadline: v })} hint={multi ? "Registration closes when the first session starts unless late joining is on (Attendance step)" : undefined} />
<DTInput label="Go Live At (optional)" value={draft.goLiveAt} onChange={v => upd({ goLiveAt: v })} hint="Leave blank to show immediately" />
</div>
{campusInfo.enabled && (
<div>
<label className="block text-xs text-gray-600 mb-1" htmlFor="event-campus">Campus</label>
<select
id="event-campus"
className="w-full border rounded px-3 py-2 text-sm bg-white"
value={draft.campusId}
onChange={e => {
const next = campusInfo.campuses.find(c => c.id === e.target.value);
const prev = campusInfo.campuses.find(c => c.id === draft.campusId);
// Swap in the campus's default location while the field still holds a default.
const isDefault = !draft.location.trim() || draft.location === (settings.org_address || "") || draft.location === (prev?.defaultLocation || "");
upd({ campusId: e.target.value, ...(next?.defaultLocation && isDefault ? { location: next.defaultLocation } : {}) });
}}
>
<option value="">All campuses</option>
{campusInfo.campuses.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
{/* An archived campus stays on events that already have it. */}
{ev?.campus && !campusInfo.campuses.some(c => c.id === ev.campus!.id) && <option value={ev.campus.id}>{ev.campus.name} (archived)</option>}
</select>
<p className="text-[10px] text-gray-400 mt-0.5">&quot;All campuses&quot; events show at every campus — for church-wide conferences and camps.</p>
</div>
)}
<div>
<label className="block text-xs text-gray-600 mb-1">Location</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.location} onChange={e => upd({ location: e.target.value })} placeholder="e.g. 123 Church St, City" />
@@ -1844,6 +1875,7 @@ function EventCard({ ev, onEdit }: { ev: Event; onEdit: () => void }) {
{isPast && !isInactive && <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">Past</span>}
{ev.isHidden && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">Hidden</span>}
{isClosed && <span className="text-[10px] px-1.5 py-0.5 rounded bg-rose-50 text-rose-700">Closed</span>}
<CampusBadge campus={ev.campus} />
</div>
<div className="text-xs text-gray-500 mt-0.5">
{scheduleSummary(ev) || <>{ev.startDate ? new Date(ev.startDate).toLocaleDateString() : ""}{ev.endDate ? ` – ${new Date(ev.endDate).toLocaleDateString()}` : ""}</>}
@@ -1897,6 +1929,7 @@ export default function ManageEventsPage() {
const [showInactive, setShowInactive] = useState(false);
const [showClosed, setShowClosed] = useState(false);
const [search, setSearch] = useState("");
const [campus, setCampus] = useState("");
const isAdmin = user?.role === "admin";
const now = Date.now();
@@ -1905,8 +1938,9 @@ export default function ManageEventsPage() {
if (!showClosed && ev.cashupStatus === "closed") return false;
if (!includePast && ev.endDate && new Date(ev.endDate).getTime() < now) return false;
if (search && !ev.title?.toLowerCase().includes(search.toLowerCase())) return false;
if (!matchesCampus(ev, campus)) return false;
return true;
}), [events, includePast, showInactive, showClosed, search, now]);
}), [events, includePast, showInactive, showClosed, search, campus, now]);
const openCreate = () => { setEditingEvent(null); setModalMode("create"); };
const openEdit = async (ev: Event) => {
@@ -1940,6 +1974,7 @@ export default function ManageEventsPage() {
<div className="flex flex-wrap items-center gap-3 mb-4 text-sm">
<input className="border rounded px-3 py-1.5 text-sm w-48" placeholder="Search by title…" value={search} onChange={e => setSearch(e.target.value)} />
<CampusFilter value={campus} onChange={setCampus} withChurchWide />
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={includePast} onChange={e => setIncludePast(e.target.checked)} /> Include past
</label>
@@ -15,6 +15,7 @@ import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActio
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
import type { DashboardStats, PaymentStats, RecentScan, ScanStats } from "@/types";
import { CampusFilter } from "@/components/shared/CampusFilter";
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
@@ -75,6 +76,7 @@ export default function SupervisorDashboardPage() {
const [overview, setOverview] = useStableState<Overview | null>(null);
const [loadingStats, setLoadingStats] = useState(false);
const hasLoadedOnce = useRef(false);
const [campus, setCampus] = useState("");
const loadStats = useCallback(async () => {
if (!token) return;
@@ -83,7 +85,8 @@ export default function SupervisorDashboardPage() {
if (isFirstLoad) setLoadingStats(true);
const [data, overviewData] = await Promise.all([
apiFetch<DashboardStats>("/api/stats/supervisor", { authToken: token }),
apiFetch<Overview>("/api/stats/overview", { authToken: token }),
// The month-over-month tiles follow the campus filter; today's totals stay church-wide.
apiFetch<Overview>(`/api/stats/overview${campus ? `?campus=${encodeURIComponent(campus)}` : ""}`, { authToken: token }),
]);
setScanStats(data.scanStats);
setPaymentStats(data.paymentStats);
@@ -95,7 +98,7 @@ export default function SupervisorDashboardPage() {
hasLoadedOnce.current = true;
if (isFirstLoad) setLoadingStats(false);
}
}, [token, setScanStats, setPaymentStats, setRecentScans, setOverview]);
}, [token, campus, setScanStats, setPaymentStats, setRecentScans, setOverview]);
useEffect(() => {
if (!token) return;
@@ -114,6 +117,7 @@ export default function SupervisorDashboardPage() {
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s what&apos;s happening with your events today.</p>
<CampusFilter value={campus} onChange={setCampus} everyLabel="All campuses (church-wide total)" className="mt-3" id="dashboard-campus" />
</div>
{!canView && (
@@ -11,6 +11,7 @@ import { errorMessage } from "@/lib/utils";
import type { Event, MessageSendResult, Registration, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { MessageKindPicker, matchedText, type MessageKind } from "@/components/shared/MessageKindPicker";
import { CampusAudiencePicker } from "@/components/shared/CampusAudiencePicker";
// Attendee with preference info
type Attendee = { id: string; name: string; phone: string; pref: string };
@@ -809,6 +810,7 @@ function WhatsAppAttendeesPageInner() {
<label className="block text-xs text-gray-600 mb-1">Users (optional)</label>
<AttendeesCheckboxDropdown attendees={users} loading={loadingUsers} selectedIds={selectedUserIds} onChange={setSelectedUserIds} channel="whatsapp" />
<div className="text-[11px] text-gray-500 mb-1">Only users with phone numbers shown. Green = WhatsApp/both preference.</div>
<CampusAudiencePicker selectedIds={selectedUserIds} onChange={setSelectedUserIds} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Event (optional for placeholders)</label>
@@ -11,6 +11,7 @@ import { TwoFactorChallenge, TwoFactorMethod } from "@/lib/auth";
import { User, Lock, ShieldAlert, ShieldCheck, ShieldOff, Trash2, Clock, LogIn, KeyRound, RotateCcw } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { useCampuses } from "@/hooks/useCampuses";
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset" | "two_factor_enabled" | "two_factor_disabled"; device: string | null; createdAt: string };
@@ -37,6 +38,8 @@ export default function UserProfilePage() {
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
const [homeCampusId, setHomeCampusId] = useState("");
const campusInfo = useCampuses();
const [profileMsg, setProfileMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [savingProfile, setSavingProfile] = useState(false);
@@ -48,6 +51,7 @@ export default function UserProfilePage() {
setEmail(user.email || "");
setPhone(user.phoneNumber || "");
setNotifPref(user.notificationPreference || "email");
setHomeCampusId(user.homeCampusId || "");
}
}, [user]);
@@ -65,6 +69,7 @@ export default function UserProfilePage() {
email,
phoneNumber: phone || null,
notificationPreference: hasValidPhone ? notifPref : "email",
...(campusInfo.enabled ? { homeCampusId: homeCampusId || null } : {}),
},
});
// Backend returns a refreshed token — save it so the session stays valid
@@ -335,6 +340,17 @@ export default function UserProfilePage() {
<input type="tel" className={inputCls} value={phone} onChange={e => setPhone(e.target.value)} placeholder="e.g. 082 123 4567" />
</div>
{campusInfo.enabled && campusInfo.campuses.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1" htmlFor="home-campus">Home campus</label>
<select id="home-campus" className={inputCls} value={homeCampusId} onChange={e => setHomeCampusId(e.target.value)}>
<option value="">No home campus</option>
{campusInfo.campuses.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<p className="mt-1 text-xs text-gray-500">The events page starts on this campus, and you&apos;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>
+3
View File
@@ -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)}
+5 -14
View File
@@ -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>
+2 -2
View File
@@ -594,14 +594,14 @@ export default function SetupPage() {
Email delivery is not configured yet. Set it up under Admin → Site Settings after logging in.
</p>
)}
{/* Licence: connect to the Hope Events Hub now, or later in Site settings → Licence. */}
{/* Licence: connect to the Cross Events Hub now, or later in Site settings → Licence. */}
<div className="text-left border rounded-xl p-4 space-y-2">
<h3 className="text-sm font-semibold text-gray-900">Licence</h3>
{licenceConnected ? (
<p className="text-sm text-green-700">✓ Connected — licensed to {licenceConnected}.</p>
) : (
<>
<p className="text-xs text-gray-500">Enter the licence key you received to connect this site to the Hope Events Hub. You can also do this later under Admin → Site Settings → Licence.</p>
<p className="text-xs text-gray-500">Enter the licence key you received to connect this site to the Cross Events Hub. Until a licence is connected, events can&apos;t be created or edited. You can also do this later under Admin → Site Settings → Licence.</p>
<ConnectForm token={setupToken} compact onConnected={s => setLicenceConnected(s.licence?.customer || "your organisation")} />
</>
)}
+14 -1
View File
@@ -23,6 +23,19 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// same degrade-gracefully posture as layout.tsx's getServerSettings.
}
// Campus pages, while campuses are on (the list is empty otherwise).
let campuses: { slug: string }[] = [];
try {
campuses = (await apiFetch<{ campuses: { slug: string }[] }>("/api/campuses", { nextOptions: { next: { revalidate } } })).campuses || [];
} catch {
// Same as events above.
}
const campusEntries: MetadataRoute.Sitemap = campuses.map((c) => ({
url: `${base}/campus/${c.slug}`,
changeFrequency: "weekly",
priority: 0.6,
}));
const eventEntries: MetadataRoute.Sitemap = (events || []).map((e) => ({
url: `${base}/events/${e.id}`,
lastModified: e.updatedAt ? new Date(e.updatedAt) : undefined,
@@ -30,5 +43,5 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
priority: 0.7,
}));
return [...staticEntries, ...eventEntries];
return [...staticEntries, ...campusEntries, ...eventEntries];
}
@@ -0,0 +1,256 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { MapPin, Plus, ArrowUp, ArrowDown, Archive, ArchiveRestore, Trash2, Pencil } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { useDismissingState } from "@/hooks/useDismissingState";
import { reloadCampuses, type Campus } from "@/hooks/useCampuses";
import { errorMessage } from "@/lib/utils";
type Person = { id: string; name: string; email: string };
type AdminCampus = Campus & {
archivedAt: string | null;
notifyRecipients: Person[];
_count: { events: number; homeOf: number };
};
type Draft = {
name: string; slug: string; address: string; defaultLocation: string;
contactName: string; contactEmail: string; contactPhone: string; colour: string; logoUrl: string;
notifyRecipients: Person[];
};
const EMPTY: Draft = { name: "", slug: "", address: "", defaultLocation: "", contactName: "", contactEmail: "", contactPhone: "", colour: "", logoUrl: "", notifyRecipients: [] };
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
const toDraft = (c: AdminCampus): Draft => ({
name: c.name, slug: c.slug, address: c.address || "", defaultLocation: c.defaultLocation || "",
contactName: c.contactName || "", contactEmail: c.contactEmail || "", contactPhone: c.contactPhone || "",
colour: c.colour || "", logoUrl: c.logoUrl || "", notifyRecipients: c.notifyRecipients || [],
});
/** Staff who get the daily summary for this campus's events. */
function RecipientsField({ value, onChange }: { value: Person[]; onChange: (v: Person[]) => void }) {
const { token } = useAuth();
const [search, setSearch] = useState("");
const [results, setResults] = useState<Person[]>([]);
useEffect(() => {
if (!search.trim()) { setResults([]); return; }
let cancelled = false;
const t = setTimeout(async () => {
try {
const res = await apiFetch<{ data?: (Person & { role?: string })[] }>(`/api/users?search=${encodeURIComponent(search.trim())}&limit=10`, { authToken: token });
if (!cancelled) setResults((res?.data || []).filter(u => u.role !== "user"));
} catch { if (!cancelled) setResults([]); }
}, 300);
return () => { cancelled = true; clearTimeout(t); };
}, [search, token]);
const shown = results.filter(r => !value.some(v => v.id === r.id));
return (
<div className="space-y-2">
{value.length > 0 && (
<ul className="flex flex-wrap gap-2">
{value.map(p => (
<li key={p.id} className="inline-flex items-center gap-1.5 text-xs bg-gray-100 rounded-full pl-3 pr-1 py-1">
{p.name}
<button type="button" onClick={() => onChange(value.filter(v => v.id !== p.id))} className="rounded-full px-1.5 hover:bg-gray-200" aria-label={`Remove ${p.name}`}>×</button>
</li>
))}
</ul>
)}
<input className={inputCls} placeholder="Search staff by name or email…" value={search} onChange={e => setSearch(e.target.value)} />
{shown.length > 0 && (
<ul className="border rounded-lg divide-y max-h-48 overflow-y-auto">
{shown.map(u => (
<li key={u.id}>
<button type="button" className="w-full text-left px-3 py-1.5 text-sm hover:bg-brand-50" onClick={() => { onChange([...value, u]); setSearch(""); setResults([]); }}>
{u.name} <span className="text-xs text-gray-400">{u.email}</span>
</button>
</li>
))}
</ul>
)}
</div>
);
}
function CampusForm({ initial, saving, onSave, onCancel }: { initial: Draft; saving: boolean; onSave: (d: Draft) => void; onCancel: () => void }) {
const [d, setD] = useState<Draft>(initial);
const set = (k: keyof Draft) => (e: React.ChangeEvent<HTMLInputElement>) => setD(prev => ({ ...prev, [k]: e.target.value }));
return (
<form onSubmit={e => { e.preventDefault(); onSave(d); }} className="border rounded-xl p-4 space-y-3 bg-gray-50">
<div className="grid sm:grid-cols-2 gap-3">
<label className="block text-sm"><span className="text-gray-700 font-medium">Name</span>
<input className={inputCls} value={d.name} onChange={set("name")} required maxLength={100} placeholder="e.g. Henley Campus" /></label>
<label className="block text-sm"><span className="text-gray-700 font-medium">Web address</span>
<input className={inputCls} value={d.slug} onChange={set("slug")} maxLength={60} placeholder="made from the name" />
<span className="text-xs text-gray-500">The campus page: /campus/{d.slug || "…"}</span></label>
<label className="block text-sm sm:col-span-2"><span className="text-gray-700 font-medium">Address</span>
<input className={inputCls} value={d.address} onChange={set("address")} maxLength={300} /></label>
<label className="block text-sm sm:col-span-2"><span className="text-gray-700 font-medium">Default event location</span>
<input className={inputCls} value={d.defaultLocation} onChange={set("defaultLocation")} maxLength={300} placeholder="Filled in when an event is created for this campus" /></label>
<label className="block text-sm"><span className="text-gray-700 font-medium">Contact name</span>
<input className={inputCls} value={d.contactName} onChange={set("contactName")} maxLength={100} /></label>
<label className="block text-sm"><span className="text-gray-700 font-medium">Contact email</span>
<input type="email" className={inputCls} value={d.contactEmail} onChange={set("contactEmail")} maxLength={200} /></label>
<label className="block text-sm"><span className="text-gray-700 font-medium">Contact phone</span>
<input className={inputCls} value={d.contactPhone} onChange={set("contactPhone")} maxLength={40} /></label>
<label className="block text-sm"><span className="text-gray-700 font-medium">Colour</span>
<span className="flex gap-2">
<input type="color" className="h-9 w-12 border rounded" value={d.colour || "#2563eb"} onChange={set("colour")} aria-label="Pick a colour" />
<input className={inputCls} value={d.colour} onChange={set("colour")} placeholder="optional, e.g. #1d4ed8" maxLength={7} />
</span></label>
<label className="block text-sm sm:col-span-2"><span className="text-gray-700 font-medium">Logo address</span>
<input className={inputCls} value={d.logoUrl} onChange={set("logoUrl")} maxLength={500} placeholder="optional, https://…" /></label>
</div>
<div>
<p className="text-sm text-gray-700 font-medium">Daily summary for this campus</p>
<p className="text-xs text-gray-500 mb-2">These staff also get the daily summary of this campus&apos;s events, on top of each event&apos;s own recipients.</p>
<RecipientsField value={d.notifyRecipients} onChange={v => setD(prev => ({ ...prev, notifyRecipients: v }))} />
</div>
<div className="flex gap-2">
<button disabled={saving || !d.name.trim()} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">{saving ? "Saving…" : "Save campus"}</button>
<button type="button" onClick={onCancel} className="px-4 py-2 rounded-lg border text-sm hover:bg-white">Cancel</button>
</div>
</form>
);
}
/** Site settings → Campuses: turn campuses on, and add, order, archive and delete them. */
export function CampusesTab({ active }: { active: boolean }) {
const { token } = useAuth();
const [enabled, setEnabled] = useState(false);
const [campuses, setCampuses] = useState<AdminCampus[] | null>(null);
const [editing, setEditing] = useState<string | "new" | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const load = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<{ enabled: boolean; campuses: AdminCampus[] }>("/api/campuses/admin", { authToken: token });
setEnabled(res.enabled);
setCampuses(res.campuses);
} catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Could not load campuses." }); }
}, [token, setMsg]);
useEffect(() => { if (active) load(); }, [active, load]);
const run = async (fn: () => Promise<unknown>, ok?: string) => {
setBusy(true);
try {
await fn();
reloadCampuses();
await load();
if (ok) setMsg({ type: "ok", text: ok });
return true;
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Something went wrong." });
return false;
} finally { setBusy(false); }
};
const toggle = (on: boolean) => run(() => apiFetch("/api/campuses/settings", { method: "PUT", authToken: token, body: { enabled: on } }), on ? "Campuses are on." : "Campuses are off — campus fields are hidden, nothing is deleted.");
const save = async (d: Draft, id?: string) => {
const body = { ...d, notifyRecipients: undefined, notifyRecipientIds: d.notifyRecipients.map(p => p.id) };
const ok = await run(() => id
? apiFetch(`/api/campuses/${id}`, { method: "PUT", authToken: token, body })
: apiFetch("/api/campuses", { method: "POST", authToken: token, body }).then((c) => {
const created = c as AdminCampus;
// Recipients are set with an update, so a new campus gets them too.
return d.notifyRecipients.length ? apiFetch(`/api/campuses/${created.id}`, { method: "PUT", authToken: token, body: { notifyRecipientIds: body.notifyRecipientIds } }) : created;
}), id ? "Campus saved." : "Campus added.");
if (ok) setEditing(null);
};
const move = (list: AdminCampus[], i: number, dir: -1 | 1) => {
const ids = list.map(c => c.id);
[ids[i], ids[i + dir]] = [ids[i + dir], ids[i]];
return run(() => apiFetch("/api/campuses/order", { method: "PUT", authToken: token, body: { ids } }));
};
const setArchived = (c: AdminCampus, archived: boolean) => run(() => apiFetch(`/api/campuses/${c.id}`, { method: "PUT", authToken: token, body: { archived } }), archived ? `${c.name} archived.` : `${c.name} restored.`);
const remove = (c: AdminCampus) => {
if (!window.confirm(`Delete ${c.name}? This can't be undone.`)) return;
run(() => apiFetch(`/api/campuses/${c.id}`, { method: "DELETE", authToken: token }), `${c.name} deleted.`);
};
if (!campuses) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
const activeList = campuses.filter(c => !c.archivedAt);
const archivedList = campuses.filter(c => c.archivedAt);
return (
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-gray-900">Campuses</h2>
<p className="text-sm text-gray-500">For a church with more than one campus: each event belongs to a campus or to all campuses, the events page gets a campus filter, and people can choose their home campus. Churches with one campus can leave this off — nobody sees campus fields then.</p>
</div>
<label className="flex items-start gap-3 border rounded-xl p-4 cursor-pointer">
<input type="checkbox" className="mt-1 h-4 w-4" checked={enabled} disabled={busy} onChange={e => toggle(e.target.checked)} />
<span>
<span className="block font-medium text-gray-900">Use campuses</span>
<span className="block text-sm text-gray-500">Turning this off hides campus fields and filters everywhere. Campuses and the campus on each event are kept, and come back when it&apos;s turned on again.</span>
</span>
</label>
{enabled && (
<>
<div className="space-y-2">
{activeList.length === 0 && editing !== "new" && <p className="text-sm text-gray-500">No campuses yet. Add your first one below.</p>}
{activeList.map((c, i) => editing === c.id ? (
<CampusForm key={c.id} initial={toDraft(c)} saving={busy} onSave={d => save(d, c.id)} onCancel={() => setEditing(null)} />
) : (
<div key={c.id} className="border rounded-xl p-3 flex flex-wrap items-center gap-3">
<span className="w-3 h-3 rounded-full shrink-0 border" style={{ background: c.colour || "transparent" }} aria-hidden />
<div className="min-w-0 flex-1">
<p className="font-medium text-gray-900">{c.name}</p>
<p className="text-xs text-gray-500">
<Link href={`/campus/${c.slug}`} className="hover:underline">/campus/{c.slug}</Link>
{" · "}{c._count.events} event{c._count.events === 1 ? "" : "s"}{" · "}{c._count.homeOf} {c._count.homeOf === 1 ? "person" : "people"} call it home
{c.address && <> · <MapPin className="inline w-3 h-3" /> {c.address}</>}
</p>
</div>
<div className="flex items-center gap-1">
<button onClick={() => move(activeList, i, -1)} disabled={busy || i === 0} className="p-1.5 rounded hover:bg-gray-100 disabled:opacity-30" aria-label="Move up"><ArrowUp className="w-4 h-4" /></button>
<button onClick={() => move(activeList, i, 1)} disabled={busy || i === activeList.length - 1} className="p-1.5 rounded hover:bg-gray-100 disabled:opacity-30" aria-label="Move down"><ArrowDown className="w-4 h-4" /></button>
<button onClick={() => setEditing(c.id)} disabled={busy} className="p-1.5 rounded hover:bg-gray-100" aria-label={`Edit ${c.name}`}><Pencil className="w-4 h-4" /></button>
<button onClick={() => setArchived(c, true)} disabled={busy} className="p-1.5 rounded hover:bg-gray-100" aria-label={`Archive ${c.name}`} title="Archive: hidden from filters and new events; its events keep it"><Archive className="w-4 h-4" /></button>
{c._count.events === 0 && (
<button onClick={() => remove(c)} disabled={busy} className="p-1.5 rounded hover:bg-red-50 text-red-600" aria-label={`Delete ${c.name}`}><Trash2 className="w-4 h-4" /></button>
)}
</div>
</div>
))}
</div>
{editing === "new" ? (
<CampusForm initial={EMPTY} saving={busy} onSave={d => save(d)} onCancel={() => setEditing(null)} />
) : (
<button onClick={() => setEditing("new")} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border text-sm hover:bg-gray-50">
<Plus className="w-4 h-4" /> Add a campus
</button>
)}
{archivedList.length > 0 && (
<div className="pt-2">
<h3 className="text-sm font-semibold text-gray-700 mb-2">Archived</h3>
<p className="text-xs text-gray-500 mb-2">Archived campuses aren&apos;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>
);
}
+33 -10
View File
@@ -21,14 +21,15 @@ export type HubStatus = {
version: string;
latestVersion: string | null;
updateAvailable: boolean;
updateRequest: { requestedAt: string; version: string } | null;
};
const STATE: Record<HubStatus["state"], { label: string; tone: string; text: string }> = {
active: { label: "Active", tone: "bg-green-50 text-green-800 border-green-200", text: "Everything works." },
grace: { label: "Needs renewing", tone: "bg-amber-50 text-amber-900 border-amber-200", text: "Everything still works for now — renew before the grace period ends." },
suspended: { label: "Suspended", tone: "bg-red-50 text-red-800 border-red-200", text: "Read-only: no new events, registrations or payments. Tickets still scan and all data can be exported." },
revoked: { label: "Revoked", tone: "bg-red-50 text-red-800 border-red-200", text: "Read-only: no new events, registrations or payments. Tickets still scan and all data can be exported." },
unlicensed: { label: "Not connected", tone: "bg-gray-50 text-gray-700 border-gray-200", text: "Connect this site to the Hope Events Hub with your licence key." },
suspended: { label: "Suspended", tone: "bg-red-50 text-red-800 border-red-200", text: "Read-only: events can't be created or edited, and no new registrations or payments. Tickets still scan and all data can be exported." },
revoked: { label: "Revoked", tone: "bg-red-50 text-red-800 border-red-200", text: "Read-only: events can't be created or edited, and no new registrations or payments. Tickets still scan and all data can be exported." },
unlicensed: { label: "No licence", tone: "bg-red-50 text-red-800 border-red-200", text: "Read-only until you connect a licence key: events can't be created or edited, and no new registrations or payments." },
};
const fmt = (d: string | null) => (d ? new Date(d).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "—");
@@ -93,6 +94,19 @@ export function LicenceTab({ active }: { active: boolean }) {
}
};
const requestUpdate = async () => {
setBusy(true);
setMsg(null);
try {
setStatus(await apiFetch<HubStatus>("/api/hub/update-request", { method: "POST", authToken: token }));
setMsg({ type: "ok", text: "Update requested. Your provider has been told and will update the site." });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "The Hub could not be reached." });
} finally {
setBusy(false);
}
};
if (!status) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
const st = STATE[status.state];
@@ -100,7 +114,7 @@ export function LicenceTab({ active }: { active: boolean }) {
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-gray-900">Licence</h2>
<p className="text-sm text-gray-500">This site checks in with the Hope Events Hub every 15 minutes: version, health and totals only — never anyone&apos;s personal information.</p>
<p className="text-sm text-gray-500">This site checks in with the Cross Events Hub 30 seconds after starting and every 15 minutes after that: version, health and totals only — never anyone&apos;s personal information.</p>
</div>
<div className={`border rounded-xl p-4 ${st.tone}`}>
@@ -114,7 +128,7 @@ export function LicenceTab({ active }: { active: boolean }) {
{!status.keyConfigured && (
<p className="text-sm text-amber-800 bg-amber-50 border border-amber-200 rounded-lg p-3">
This version has no Hub key built in yet, so it can&apos;t check licences. Update Hope Events, or set HUB_PUBLIC_KEY on the server for testing.
This version has no Hub key built in yet, so it can&apos;t check licences. Update Cross Events, or set HUB_PUBLIC_KEY on the server for testing.
</p>
)}
@@ -147,12 +161,21 @@ export function LicenceTab({ active }: { active: boolean }) {
<div className="border rounded-xl p-4 text-sm">
<div className="flex items-center gap-2 font-medium text-gray-900 mb-1"><ArrowUpCircle className="w-4 h-4" /> Version</div>
<p>This site runs Hope Events <strong>{status.version}</strong>.{status.latestVersion && !status.updateAvailable && " It's up to date."}</p>
{status.updateAvailable && (
<p className="mt-1 text-brand-700">
Version {status.latestVersion} is available. On the server: <code className="bg-gray-100 px-1 rounded">bash scripts/deploy.sh v{status.latestVersion}</code>
<p>This site runs Cross Events <strong>{status.version}</strong>.{status.latestVersion && !status.updateAvailable && " It's up to date."}</p>
{status.updateAvailable && (status.updateRequest ? (
<p className="mt-1 text-gray-600">
Version {status.latestVersion} is available. Update requested on {fmt(status.updateRequest.requestedAt)} — your provider will install it.
</p>
)}
) : (
<div className="mt-2 flex flex-wrap items-center gap-3">
<p className="text-brand-700">Version {status.latestVersion} is available.</p>
{status.connected && (
<button onClick={requestUpdate} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
<ArrowUpCircle className="w-4 h-4" /> {busy ? "Requesting…" : "Request update"}
</button>
)}
</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>}
@@ -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 -1
View File
@@ -14,6 +14,7 @@ import { HorizontalBarChart } from "./charts/HorizontalBarChart";
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
import { errorMessage } from "@/lib/utils";
import { registrationOptionUnitPrice } from "@/lib/pricing";
import { CampusFilter, matchesCampus } from "@/components/shared/CampusFilter";
import type { ConsentRow, Event, EventCashup, EventFinancials, MedicalRow, Payment, Registration, Ticket, UserSummary } from "@/types";
const METHOD_KEYS = ["cash", "card", "eft", "other"] as const;
@@ -164,6 +165,8 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
// hiding them by default would work against those reports' whole purpose. This toggle
// lets staff exclude them when they only want currently-open events.
const [showClosedEvents, setShowClosedEvents] = useState(true);
// Multi-campus: one campus's events, church-wide ones, or all (the church-wide total).
const [campus, setCampus] = useState("");
const isAdmin = role === "admin";
const filteredEvents = useMemo(() => {
@@ -172,9 +175,10 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
if (!showPastEvents && ev.endDate && new Date(ev.endDate) < now) return false;
if (!showInactiveEvents && ev.isActive === false) return false;
if (!showClosedEvents && ev.cashupStatus === "closed") return false;
if (!matchesCampus(ev, campus)) return false;
return true;
});
}, [events, showPastEvents, showInactiveEvents, showClosedEvents]);
}, [events, showPastEvents, showInactiveEvents, showClosedEvents, campus]);
useEffect(() => {
(async () => {
@@ -1457,6 +1461,15 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
)}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
<CampusFilter
value={campus}
onChange={v => { setCampus(v); setSelectedEventIds([]); }}
withChurchWide
everyLabel="All campuses (church-wide total)"
className="mb-3"
id="reports-campus"
/>
<ReportsShell
events={filteredEvents}
isAdmin={isAdmin}
@@ -0,0 +1,66 @@
"use client";
import React, { useState } from "react";
import { Users } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { useCampuses } from "@/hooks/useCampuses";
import { errorMessage } from "@/lib/utils";
/**
* "Everyone at campus X" for the email/WhatsApp broadcast composers: adds the people whose
* home campus is X — and, ticked, people registered for X's events in the last 12 months — to
* the selected users. News consent is still checked when the message is sent. Renders
* nothing while campuses are off.
*/
export function CampusAudiencePicker({ selectedIds, onChange }: { selectedIds: string[]; onChange: (ids: string[]) => void }) {
const { token } = useAuth();
const { enabled, campuses } = useCampuses();
const [campusId, setCampusId] = useState("");
const [registrants, setRegistrants] = useState(true);
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null);
if (!enabled || campuses.length === 0) return null;
const add = async () => {
if (!campusId) return;
setBusy(true);
setNote(null);
try {
const res = await apiFetch<{ campus: { name: string }; users: { id: string }[] }>(`/api/campuses/${campusId}/audience${registrants ? "?registrants=true" : ""}`, { authToken: token });
const ids = res.users.map(u => u.id);
const merged = Array.from(new Set([...selectedIds, ...ids]));
onChange(merged);
const added = merged.length - selectedIds.length;
const people = (n: number) => `${n} ${n === 1 ? "person" : "people"}`;
setNote({
ok: true,
text: ids.length === 0 ? `Nobody at ${res.campus.name} yet.`
: added === ids.length ? `Added ${people(added)} at ${res.campus.name}.`
: `Added ${people(added)} at ${res.campus.name} — ${ids.length - added} already selected.`,
});
} catch (e) {
setNote({ ok: false, text: errorMessage(e) || "Could not load the campus's people." });
} finally {
setBusy(false);
}
};
return (
<div className="border rounded-lg p-3 bg-gray-50 space-y-2">
<p className="text-xs font-medium text-gray-700 flex items-center gap-1.5"><Users className="w-3.5 h-3.5" /> Everyone at a campus</p>
<div className="flex flex-wrap items-center gap-2">
<select className="border rounded px-2 py-1.5 text-sm bg-white" value={campusId} onChange={e => setCampusId(e.target.value)} aria-label="Campus">
<option value="">Choose a campus…</option>
{campuses.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<button type="button" onClick={add} disabled={!campusId || busy} className="px-3 py-1.5 rounded border text-sm bg-white hover:bg-gray-100 disabled:opacity-50">{busy ? "Adding…" : "Add them"}</button>
</div>
<label className="flex items-start gap-2 text-xs text-gray-600">
<input type="checkbox" className="mt-0.5" checked={registrants} onChange={e => setRegistrants(e.target.checked)} />
Also people who registered for this campus&apos;s events in the last 12 months (not only those who chose it as their home campus)
</label>
{note && <p className={`text-xs ${note.ok ? "text-green-700" : "text-red-700"}`}>{note.text}</p>}
</div>
);
}
@@ -0,0 +1,58 @@
"use client";
import React from "react";
import Link from "next/link";
import { MapPin } from "lucide-react";
import { useCampuses, type EventCampus } from "@/hooks/useCampuses";
/**
* A campus dropdown for lists and reports. Renders nothing while campuses are off.
* Values: "" = every campus (church-wide total), "all" = only events for all campuses
* (when `withChurchWide`), otherwise a campus id.
*/
export function CampusFilter({ value, onChange, withChurchWide = false, everyLabel = "All campuses", className = "", id = "campus-filter" }: {
value: string;
onChange: (value: string) => void;
withChurchWide?: boolean;
everyLabel?: string;
className?: string;
id?: string;
}) {
const { enabled, campuses } = useCampuses();
if (!enabled || campuses.length === 0) return null;
return (
<label className={`inline-flex items-center gap-2 text-sm ${className}`} htmlFor={id}>
<span className="sr-only">Campus</span>
<MapPin className="w-4 h-4 text-gray-400" aria-hidden />
<select id={id} className="border rounded px-2 py-1.5 text-sm bg-white" value={value} onChange={e => onChange(e.target.value)}>
<option value="">{everyLabel}</option>
{campuses.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
{withChurchWide && <option value="all">Church-wide events only</option>}
</select>
</label>
);
}
/** Does an event pass a CampusFilter value? */
export function matchesCampus(ev: { campusId?: string | null }, value: string): boolean {
if (!value) return true;
if (value === "all") return !ev.campusId;
return ev.campusId === value;
}
/**
* The campus an event belongs to, as a small label (`link`: to the campus page). Nothing for
* church-wide events or while campuses are off.
*/
export function CampusBadge({ campus, className = "", link = false }: { campus?: EventCampus | null; className?: string; link?: boolean }) {
const { enabled } = useCampuses();
if (!enabled || !campus) return null;
const cls = `inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-700 ${link ? "hover:bg-gray-200" : ""} ${className}`;
const inner = (
<>
<span className="w-2 h-2 rounded-full border" style={{ background: campus.colour || "transparent" }} aria-hidden />
{campus.name}
</>
);
return link ? <Link href={`/campus/${campus.slug}`} className={cls}>{inner}</Link> : <span className={cls}>{inner}</span>;
}
@@ -37,24 +37,25 @@ export function LicenceBanner() {
<div role="alert" className="bg-red-50 border-b border-red-200 text-red-800 text-sm px-4 py-2 flex items-center gap-2">
<ShieldAlert className="w-4 h-4 shrink-0" />
<span>
The licence is {status.state === "revoked" ? "revoked" : "suspended"}: the site is read-only — no new events, registrations or payments. Tickets still scan and all data can be exported. {link}
The licence is {status.state === "revoked" ? "revoked" : "suspended"}: the site is read-only — events can&apos;t be created or edited, and no new registrations or payments. Tickets still scan and all data can be exported. {link}
</span>
</div>
);
}
if (status.state === "unlicensed" && status.keyConfigured) {
if (status.state === "unlicensed" && status.readOnly) {
return (
<div role="status" className="bg-gray-50 border-b text-gray-700 text-sm px-4 py-2 flex items-center gap-2">
<div role="alert" className="bg-red-50 border-b border-red-200 text-red-800 text-sm px-4 py-2 flex items-center gap-2">
<KeyRound className="w-4 h-4 shrink-0" />
<span>This site isn&apos;t connected to the Hope Events Hub yet. {link}</span>
<span>This site has no licence, so it&apos;s read-only — events can&apos;t be created or edited. Connect your licence key in {link}</span>
</div>
);
}
if (status.updateAvailable) {
// Once an update has been requested there's nothing more for the admin to do.
if (status.updateAvailable && !status.updateRequest) {
return (
<div role="status" className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm px-4 py-2 flex items-center gap-2">
<ArrowUpCircle className="w-4 h-4 shrink-0" />
<span>Hope Events {status.latestVersion} is available (this site runs {status.version}). {link}</span>
<span>Cross Events {status.latestVersion} is available (this site runs {status.version}). Request the update in {link}</span>
</div>
);
}
+29 -2
View File
@@ -1,5 +1,5 @@
import React from "react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play, CreditCard, ArrowUpDown, KeyRound } from "lucide-react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play, CreditCard, ArrowUpDown, KeyRound, ArrowUpCircle, MapPinned, Archive, Users } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -32,6 +32,30 @@ export const siteSettingsHelpContent: HelpContent = {
</div>
),
},
{
key: "campuses",
label: "Campuses",
icon: MapPinned,
content: (
<div className="space-y-4">
<p>For a church with more than one campus. Off by default — a church with one campus never sees campus fields anywhere.</p>
<div className="space-y-4">
<GuideItem icon={MapPinned} title="Turning campuses on" tone="brand">
Tick <strong>Use campuses</strong>, then add each campus: its name, web address (its page is <code>/campus/…</code>), address, contact, colour and the default location for its events. The order you set is the order of every campus list.
</GuideItem>
<GuideItem icon={MapPinned} title="Events and the public site" tone="brand">
Each event belongs to one campus, or to <strong>All campuses</strong> for church-wide conferences and camps. The events page gets a campus filter (remembered per visitor, starting on their home campus); a campus shows its own events plus the church-wide ones.
</GuideItem>
<GuideItem icon={Users} title="People and messages" tone="gray">
People can choose a home campus on their profile. In Email and WhatsApp broadcasts, <strong>Everyone at a campus</strong> adds the people whose home campus it is — and, ticked, people who registered for its events in the last 12 months. News still only goes to people who opted in.
</GuideItem>
<GuideItem icon={Archive} title="Archiving, deleting, turning off" tone="amber">
A campus with events can&apos;t be deleted — archive it instead: it disappears from filters and new events, and its events keep it. Turning campuses off hides everything campus-related; nothing is deleted, and it all comes back when you turn them on again.
</GuideItem>
</div>
</div>
),
},
{
key: "branding",
label: "Branding",
@@ -145,7 +169,7 @@ export const siteSettingsHelpContent: HelpContent = {
icon: KeyRound,
content: (
<div className="space-y-4">
<p>The site&apos;s licence and its connection to the Hope Events Hub. The site checks in every 15 minutes with its version, health and totals — never anyone&apos;s personal information.</p>
<p>The site&apos;s licence and its connection to the Cross Events Hub. The site checks in every 15 minutes with its version, health and totals — never anyone&apos;s personal information.</p>
<div className="space-y-4">
<GuideItem icon={KeyRound} title="Connecting" tone="brand">
Enter the licence key you received (CEH-XXXX-XXXX-XXXX-XXXX). If the site moves to a new address, ask your provider to release the licence, then connect again.
@@ -156,6 +180,9 @@ export const siteSettingsHelpContent: HelpContent = {
<GuideItem icon={Scale} title="Read-only" tone="gray">
When a licence is suspended, revoked or past its grace period, nothing new can be added — no events, registrations or payments. Tickets for events people already paid for still scan, and all data can still be viewed and exported.
</GuideItem>
<GuideItem icon={ArrowUpCircle} title="Updates" tone="brand">
When a new version is out, press Request update. Your provider is told and updates the site for you — there&apos;s nothing to run yourself.
</GuideItem>
</div>
</div>
),
@@ -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 &quot;Multiple sessions&quot; 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 (&quot;Week 1 — …&quot;), 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.
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
export type Campus = {
id: string;
name: string;
slug: string;
address: string | null;
colour: string | null;
logoUrl: string | null;
defaultLocation: string | null;
contactName: string | null;
contactEmail: string | null;
contactPhone: string | null;
order: number;
};
/** What an event carries about its campus. */
export type EventCampus = { id: string; name: string; slug: string; colour: string | null };
type CampusState = { enabled: boolean; campuses: Campus[] };
// One request per page load, shared by every component that asks.
let cache: Promise<CampusState> | null = null;
function load(): Promise<CampusState> {
if (!cache) {
cache = apiFetch<CampusState>("/api/campuses").catch(() => {
cache = null; // try again next time
return { enabled: false, campuses: [] };
});
}
return cache;
}
/** Forget the cached list (after Site settings → Campuses changes it). */
export function reloadCampuses() {
cache = null;
}
/**
* Whether campuses are on (Site settings → Campuses) and the active campuses. While off,
* `enabled` is false and `campuses` is empty, so callers hide every campus field.
*/
export function useCampuses() {
const [state, setState] = useState<CampusState & { loading: boolean }>({ enabled: false, campuses: [], loading: true });
useEffect(() => {
let alive = true;
load().then(s => { if (alive) setState({ ...s, loading: false }); });
return () => { alive = false; };
}, []);
return state;
}
+2
View File
@@ -13,6 +13,8 @@ export type AuthUser = {
newsPromptPending?: boolean;
// POPIA: a privacy notice version they must accept has been published since they last did.
noticePromptPending?: boolean;
// Multi-campus: their home campus (the events page starts on it).
homeCampusId?: string | null;
};
export type AuthResponse = AuthUser & {
+5
View File
@@ -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;
+3 -3
View File
@@ -1,8 +1,8 @@
# Hope Events — Event Management Platform for Churches
# Cross Events — Event Management Platform for Churches
## What is it?
Hope Events is a complete event management platform built specifically for churches and faith-based organisations. It handles everything from advertising upcoming events and taking online registrations to collecting payments and donations, sending QR-coded tickets, checking people in at the door, and balancing the books afterwards.
Cross Events is a complete event management platform built specifically for churches and faith-based organisations. It handles everything from advertising upcoming events and taking online registrations to collecting payments and donations, sending QR-coded tickets, checking people in at the door, and balancing the books afterwards.
It runs in any web browser — on a phone, tablet, or computer — with nothing to install.
@@ -76,7 +76,7 @@ It runs in any web browser — on a phone, tablet, or computer — with nothing
## Pricing
Hope Events is offered as a **hosted service**. You don't need to worry about servers, databases, or software updates — we handle everything.
Cross Events is offered as a **hosted service**. You don't need to worry about servers, databases, or software updates — we handle everything.
| Plan | Details |
|------|---------|
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.41.0",
"version": "1.43.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.41.0",
"version": "1.43.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.64.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.41.0",
"version": "1.43.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",
+2
View File
@@ -46,6 +46,8 @@ export default defineConfig({
FRONTEND_URL: `http://localhost:${WEB_PORT}`,
APP_BASE_URL: `http://localhost:${WEB_PORT}`,
JOBS_ENABLED: "false",
// The test site has no licence; without this it would be read-only.
LICENCE_CHECK_DISABLED: "true",
SCHEDULED_EMAILS_ENABLED: "false",
// The kiosk test signs a supervisor in with a password (no 2FA code by email).
TWO_FACTOR_REQUIRED_ROLES: "false",
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Installs Hope Events on a fresh Ubuntu server (22.04 or 24.04): Node 20, PostgreSQL, PM2,
# Installs Cross Events on a fresh Ubuntu server (22.04 or 24.04): Node 20, PostgreSQL, PM2,
# nginx and an HTTPS certificate, then the app itself. Finish in the browser at /setup.
#
# Usage (as root, or with sudo):