Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35805f0c79 | ||
|
|
174022296b | ||
|
|
bc22a94f92 | ||
|
|
bbde6791cb | ||
|
|
e9a2ce9d01 | ||
|
|
39885a8719 | ||
|
|
1ce6f23ad8 | ||
|
|
1dbc37ba08 | ||
|
|
42a369278e | ||
|
|
e249d43c79 | ||
|
|
00b465b97d | ||
|
|
794396ca9c | ||
|
|
09ef2c1df1 | ||
|
|
36c3b117ca | ||
|
|
cad075bb3f | ||
|
|
159728d269 | ||
|
|
c570fc7ffb | ||
|
|
00fd26f3a5 | ||
|
|
344f260011 | ||
|
|
d2d9282a2c | ||
|
|
f36e84fd90 | ||
|
|
a6909ce506 | ||
|
|
955f8d9831 | ||
|
|
2727fc6554 | ||
|
|
3d149e6098 | ||
|
|
30a2142085 | ||
|
|
95913a5bd5 | ||
|
|
f7e0ec8bc1 | ||
|
|
08d442e1c1 | ||
|
|
be4f341e42 | ||
|
|
1caa0f0ea7 | ||
|
|
91f6481fb7 | ||
|
|
bf887720a1 | ||
|
|
6de0b5525b | ||
|
|
e261705214 | ||
|
|
4ef9cea634 | ||
|
|
6f18e40f06 | ||
|
|
7fdd2b8b9d | ||
|
|
8e2cb7eb4d | ||
|
|
bc73d7e3af | ||
|
|
925252a1f2 | ||
|
|
60726b71ce | ||
|
|
e58787ae7a | ||
|
|
8f533e1f61 | ||
|
|
3c5246424b | ||
|
|
bbdceb38b6 | ||
|
|
c0965bfcf5 | ||
|
|
eb2159fa33 | ||
|
|
0551226de5 | ||
|
|
1ae9c45eed | ||
|
|
fd2bb07047 | ||
|
|
2d4bc5c38d | ||
|
|
f7a19f254a | ||
|
|
8c5e6bd23c | ||
|
|
a1ecbb9617 | ||
|
|
6eee10b610 |
@@ -1,129 +0,0 @@
|
||||
# Checks every push and pull request: backend tests, frontend lint/typecheck/build, and
|
||||
# the Playwright end-to-end tests. Pushing a version tag (v1.2.3) also deploys, once the
|
||||
# DEPLOY_* secrets are set (Repository → Settings → Actions → Secrets).
|
||||
#
|
||||
# Needs a Gitea Actions runner with the "ubuntu-latest" label (Docker), e.g. act_runner:
|
||||
# https://docs.gitea.com/usage/actions/act-runner
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
# Throwaway values for the test database and tokens — never production secrets.
|
||||
JWT_SECRET: ci-only-jwt-secret-not-for-production
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/events_ci
|
||||
E2E_DATABASE_URL: postgresql://postgres:postgres@postgres:5432/events_ci_e2e
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend tests
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: events_ci
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: backend
|
||||
- name: Migrations apply cleanly
|
||||
run: npx prisma migrate deploy
|
||||
working-directory: backend
|
||||
- run: npm test
|
||||
working-directory: backend
|
||||
|
||||
frontend:
|
||||
name: Frontend lint, typecheck, build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: frontend
|
||||
- run: npx eslint src
|
||||
working-directory: frontend
|
||||
- run: npx tsc --noEmit -p .
|
||||
working-directory: frontend
|
||||
- run: npm run build
|
||||
working-directory: frontend
|
||||
env:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:5000
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
|
||||
e2e:
|
||||
name: End-to-end tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend]
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: events_ci
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- run: npm ci
|
||||
- run: npm ci
|
||||
working-directory: backend
|
||||
- run: npm ci
|
||||
working-directory: frontend
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- run: npx playwright test
|
||||
env:
|
||||
CI: "true"
|
||||
- name: Keep the report when tests fail
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
e2e/report
|
||||
e2e/test-results
|
||||
retention-days: 7
|
||||
|
||||
deploy:
|
||||
name: Deploy tagged release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend, e2e]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- name: Deploy over SSH (skipped until DEPLOY_HOST is set)
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
if [ -z "$DEPLOY_HOST" ]; then
|
||||
echo "DEPLOY_HOST isn't set — skipping the deploy."
|
||||
exit 0
|
||||
fi
|
||||
mkdir -p ~/.ssh
|
||||
echo "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"cd '${DEPLOY_PATH:-~/hope-events}' && bash scripts/deploy.sh '$TAG'"
|
||||
+259
@@ -7,6 +7,265 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.46.1] - 2026-10-28
|
||||
|
||||
### Removed
|
||||
|
||||
- The Gitea CI workflow (`.gitea/workflows/ci.yml`). There's no Actions runner, so every push
|
||||
left a run stuck on "Waiting". Tests and lint are run by hand before a release, and releases
|
||||
are deployed with `scripts/deploy.sh` on the server.
|
||||
|
||||
## [1.46.0] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Volunteer rosters** — plan who serves where (Supervisor → **Volunteer rosters**).
|
||||
- **Serving roles** (Welcome, Parking, Kids, Sound…) per event, or per session on a multi-session
|
||||
event, with the number of people needed, optional shift times and a short description. Each
|
||||
event or session has its own roster; nothing repeats by itself.
|
||||
- **Anyone with an account can volunteer:** My account → **Serving** lists events looking for
|
||||
people and lets you sign up for a role that isn't full. Supervisors can also put someone on a
|
||||
role (even beyond the number needed). Nobody can be in two roles at the same time. Supervisors
|
||||
get a sign-up link to share (it works for hidden events too).
|
||||
- **Reminders** by WhatsApp or email the day before a shift.
|
||||
- **"Can't make it" swaps:** the volunteer asks for cover, the event's other volunteers are told,
|
||||
and the first to accept takes the place straight away — no approval. The event's notification
|
||||
recipients get an email about every swap and every withdrawal.
|
||||
- **Check-in on the day:** volunteers scan the event's check-in QR code (show it on a screen or
|
||||
print it) with their phone camera and are checked in for that day's shifts; staff can tick
|
||||
people in on Staff → **Volunteers**, and undo it.
|
||||
- **Kids rooms:** a role can be linked to a kids room. Its volunteers count as leaders on duty
|
||||
in that room once they check in (for the room's ratio), and the room screen lists who is
|
||||
rostered but not in yet.
|
||||
- **Free volunteer ticket**, off by default: choose a ticket per event (or add a free
|
||||
"Volunteer" ticket) and everyone on the roster gets a free place on it unless they already have
|
||||
a ticket. It's given back when they leave the roster.
|
||||
- **Roster sheet** with phone numbers, check-in times and a printable view; fill rate per
|
||||
event; **hours served** per person across events.
|
||||
- Audit log entries for the volunteer ticket, role changes, people put on or taken off a roster
|
||||
and check-ins by hand; opening a roster is in the access log. "Download my data" includes
|
||||
your shifts; anonymising an account gives up its future shifts.
|
||||
- Background job `volunteers` (every 15 minutes, `VOLUNTEER_JOBS_ENABLED`) sends the reminders.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The kids check-in ticket was offered publicly:** the free "Kids check-in" ticket from 1.45.0
|
||||
showed on the event page and could be booked by anyone. It (and the new volunteer ticket) is now
|
||||
left out of public event pages and lists and refused on public registration; staff still see it.
|
||||
|
||||
## [1.45.1] - 2026-10-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Privacy notice (built-in text)** brought up to date: kids check-in (what's kept about
|
||||
children and authorised adults, who sees allergies, medical and custody notes, pickup codes,
|
||||
offline desk lists, the 6-month deletion), multi-session attendance, session reminders,
|
||||
certificates and their public check, home campus, WhatsApp sign-in codes, and campus-limited
|
||||
staff. "Stored on South African servers" is replaced by where the data really is: the site's
|
||||
own database at the hosting provider in the operator list. It now also says what the site sends
|
||||
Cross Code's licence server (health and totals, income as a range — no personal information),
|
||||
and lists the kids check-in browser storage.
|
||||
Sites that have already published their own notice keep it: review the changes and publish a
|
||||
new version from Admin → Privacy → Privacy notice.
|
||||
- **Terms of use:** a new "Kids Check-in" section (pickup codes, parents' responsibility for
|
||||
their children's details), sessions and certificates, kids messages among the required
|
||||
messages, and account deletion from the Privacy page. Later sections are renumbered.
|
||||
|
||||
## [1.45.0] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Kids check-in** — a secure, fully digital check-in and pickup for children's ministry, run
|
||||
from a staff desk. No label printers needed.
|
||||
- **Families are kept between events** (weekly kids church): parents add their children under
|
||||
My account → **Kids** (date of birth, allergies, medical and custody notes) and list
|
||||
**authorised adults** who may collect them (no account needed). Staff can find a family by
|
||||
name or phone, scan the parent's family QR code, or add a new family on the spot.
|
||||
- **Pickup codes:** checking in gives the family a 4-character code for the day (or session),
|
||||
shared by the children checked in together. It shows on the parent's phone with a QR code and
|
||||
is sent by WhatsApp or email (and, if chosen, to an authorised adult). Anyone with the code can
|
||||
collect.
|
||||
- **"Write on the sticker" panel:** first name, room, code and a short ALLERGY/MEDICAL alert in
|
||||
large print, for volunteers to copy onto a blank sticker.
|
||||
- **Rooms** with an age range, capacity and ratio (children per leader). The desk suggests the
|
||||
room from the child's age; the room screen shows live cards with allergies, medical and custody
|
||||
notes, "I'm on duty", and warns when a room is full or over the ratio.
|
||||
- **Check-out** by typing or scanning the code. A wrong code is refused, shown as an alert on the
|
||||
room screens and recorded in the audit log; checking out without the code needs a reason.
|
||||
"Call parent" asks the parents to come to the room.
|
||||
- **Offline desk:** download the families and 20 pickup codes for the device beforehand, keep
|
||||
checking in without a connection, and sync afterwards.
|
||||
- Children show on the **attendance register** and reports: each gets a free place on the event's
|
||||
kids ticket the first time, and every check-in counts as attendance.
|
||||
- Set up per event under Supervisor → **Kids check-in setup**; works per campus.
|
||||
- **Privacy:** allergies, medical and custody notes are encrypted and never put in messages;
|
||||
opening the desk or room screens is written to the access log; families are in "Download my
|
||||
data" and anonymising; a new retention rule deletes a child's profile after **6 months**
|
||||
without a check-in (on by default); a readiness check for it.
|
||||
- Rooms in Rooms & groups take an age range and a ratio (`minAge`, `maxAge`, `ratio`).
|
||||
|
||||
### Changed
|
||||
|
||||
- Apple and Google Wallet tickets are removed from the 2.0 plan.
|
||||
|
||||
## [1.44.0] - 2026-10-27
|
||||
|
||||
### Added
|
||||
|
||||
- **Staff for some campuses** (Admin → Users → "Limit to campuses", while campuses are on). A
|
||||
supervisor or staff member limited to some campuses sees only those campuses' events plus
|
||||
events for all campuses — in every list, report, dashboard, the scanner and offline scanning.
|
||||
- **Events for all campuses:** they can scan, register people and take payments at the door, but
|
||||
not change them, cash them up or refund them (the event editor opens read-only).
|
||||
- **New events** go at one of their campuses; they can't move events to another campus.
|
||||
- **Admins and supervisors for every campus only:** the site banner, payment reconciliation and
|
||||
open online checkouts, and donations not linked to an event.
|
||||
- **Messages:** broadcasts only to people at their campuses or attendees of their events (no
|
||||
typed-in addresses). **Coupons:** only for their campuses' events.
|
||||
- Another campus's records answer "not found"; the **scanner says which campus** a ticket is
|
||||
for. Staff still see their own registrations for any campus's event.
|
||||
- Their dashboard says which campuses they work with. Admins are never limited, and limits are
|
||||
ignored while campuses are off (the Campuses tab warns first). Changing someone's limits is in
|
||||
the audit log; a campus with staff limited to it can't be deleted.
|
||||
- Every staff and supervisor route declares how it handles campus limits, and a test fails on
|
||||
any that doesn't (`tests/campusScopeRoutes.test.js`).
|
||||
|
||||
## [1.43.0] - 2026-10-27
|
||||
|
||||
### Added
|
||||
|
||||
- **Campuses** (Site settings → Campuses, off by default) for a church with more than one campus
|
||||
in one install. Churches with one campus see no campus fields anywhere.
|
||||
- Admins turn campuses on, then add, order, archive and delete campuses: name, web address,
|
||||
address, contact, colour, logo, default event location, and staff who get that campus's daily
|
||||
summary. A campus with events can't be deleted, only archived. Turning campuses off hides
|
||||
everything campus-related and deletes nothing.
|
||||
- **Events** belong to one campus or to **All campuses** (church-wide). Choosing a campus fills
|
||||
in its default location; moving an event to another campus is in the audit log. The Events
|
||||
list in the dashboard shows each event's campus and has a campus filter.
|
||||
- **Public site:** a campus filter on the events page, remembered in the visitor's browser and
|
||||
starting on their home campus; a campus shows its own events plus church-wide ones. The campus
|
||||
shows on event cards, the event page and ticket PDFs. **Campus pages** at `/campus/<slug>`
|
||||
(also in the sitemap).
|
||||
- **Home campus** on the profile page.
|
||||
- **Reports and dashboards:** a campus filter on Reports (all reports and the funnel) and on the
|
||||
admin and supervisor dashboards' past-month tiles, with a church-wide total.
|
||||
- **Broadcasts:** "Everyone at a campus" in Email and WhatsApp broadcasts — people whose home
|
||||
campus it is, plus (ticked) people who registered for its events in the last 12 months. News
|
||||
consent still applies.
|
||||
- **Daily summary:** campus recipients also get the summary of their campus's events.
|
||||
- The Hub heartbeat reports whether campuses are on and how many there are (`campusCount`).
|
||||
- Campus-scoped staff (supervisors and staff limited to some campuses) follow in 1.44.0.
|
||||
- The privacy notice lists the new `hope_events_campus` browser-storage item.
|
||||
|
||||
### Fixed
|
||||
|
||||
- "Request update" (1.42.0) wasn't written to the audit log: the `hub_update_requested` action was
|
||||
missing from the database. The request itself always worked.
|
||||
|
||||
## [1.42.1] - 2026-10-27
|
||||
|
||||
### Changed
|
||||
|
||||
- **The production Hub's public key is built in** (`backend/src/hub/publicKey.js`), so sites can
|
||||
check licences without `HUB_PUBLIC_KEY` in their `.env`. `HUB_PUBLIC_KEY` still overrides it (for
|
||||
a local Hub while developing).
|
||||
|
||||
## [1.42.0] - 2026-10-27
|
||||
|
||||
### Changed
|
||||
|
||||
- **"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
|
||||
|
||||
- **Hope Events Hub connection** (`docs/hub-protocol.md`). Each install connects to the Hub
|
||||
(`events-hub.crosscode.co.za`) with a licence key:
|
||||
- **Licence step in `/setup`** (on the last screen), and **Site settings → Licence** for existing
|
||||
installs: connect, see the licence (licensed to, plan, end date, grace period), the last check-in,
|
||||
and "Check now".
|
||||
- A **heartbeat every 15 minutes** (`hub-heartbeat` job; `HUB_HEARTBEAT_ENABLED=false` to switch off):
|
||||
version, health (database, background jobs, last backup, free disk, server errors, pending
|
||||
migrations) and totals (users, events, registrations, tickets, check-ins, payment providers, income
|
||||
as a range only). Never anyone's personal information.
|
||||
- **Licences are checked on the site** with the Hub's public key, so a Hub outage never switches a
|
||||
site off and a licence can't be faked by editing the database:
|
||||
- **Grace period** (licence past its end date, default 14 days): everything works; **only site
|
||||
admins** see "Licence needs renewing — X days left".
|
||||
- **Suspended or revoked**: read-only — no new events, registrations, tickets, payments, waitlist
|
||||
places, coupons or bundles. Tickets still scan; all data can still be viewed and exported;
|
||||
refunds, cancellations and privacy requests still work.
|
||||
- Not connected yet: works as before; admins see "Connect to the Hub".
|
||||
- **Update available**: admins see when a newer version is out, with the command to run.
|
||||
- **`/health`** — status, version, database, background jobs and last backup, for monitoring.
|
||||
- **Install script** (`scripts/install.sh`): a fresh Ubuntu server to a running site in one command —
|
||||
Node 20, PostgreSQL, PM2, nginx, HTTPS, configuration, migrations, build; then `/setup`.
|
||||
|
||||
### Changed
|
||||
|
||||
- The generic settings endpoints never read or write the Hub connection (install id, secret, licence).
|
||||
|
||||
## [1.40.0] - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- **POPIA toolkit, part 3.** Three new tabs on Admin → Privacy (POPIA):
|
||||
- **Readiness** (now the first tab): a checklist with a score — Information Officer and legal details set, privacy notice published, operators described (no generic names, a location for each), retention reviewed and the job running, no overdue requests, breaches reported to the Regulator, unsubscribe links working (`BACKEND_URL` on https), email set up, WhatsApp STOP replies arriving, two-factor login required, backups running. Each open item links to where you fix it.
|
||||
- **Retention**: how long personal information is kept, with what each setting would affect right now. A nightly job (`retention`, 03:30; `RETENTION_JOBS_ENABLED=false` to switch off) applies it:
|
||||
- **Inactive accounts** — no login, registration, payment or being named as an attendee for **5 years**: a warning by email (or WhatsApp) 30 days before, then the account is anonymised. Logging in keeps it. Never staff, supervisors or admins, and never while they have a registration for an upcoming event. **On by default.**
|
||||
- **Attendee form answers** — deleted 12 months after the event.
|
||||
- **Login history** — kept 24 months.
|
||||
- **Payer names and emails in Yoco/PayFast notifications** — removed after 24 months (payments themselves are kept).
|
||||
- **Audit log, access log and "view as" sessions** — kept 5 years.
|
||||
- Medical details, visit statistics and backups keep their existing settings, shown alongside. "Run now" applies them straight away.
|
||||
- **Breaches**: a register of security compromises (what happened, what information, who, consequences, what was done, advice to the people affected), with status and the dates the Information Regulator and the people affected were told. Each entry has a draft message for the people affected and a summary for the Regulator's form, ready to copy.
|
||||
- **Children's and medical information** is flagged in the access log (medical lists, and anything about an event that asks for parental consent), with a filter.
|
||||
- The privacy notice gets a `{retention}` placeholder — one line per retention period, following the settings. The built-in notice uses it; if you already published your own version, add a line with `{retention}` to your retention section.
|
||||
|
||||
## [1.39.0] - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
# 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.
|
||||
|
||||
## 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. |
|
||||
+155
-16
@@ -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,15 @@ 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. [Kids check-in](#kids-check-in)
|
||||
18. [Volunteer rosters](#volunteer-rosters)
|
||||
19. [Accounts & security](#accounts--security)
|
||||
20. [Privacy (POPIA)](#privacy-popia)
|
||||
21. [Help system](#help-system)
|
||||
22. [Background jobs](#background-jobs)
|
||||
23. [Operations](#operations)
|
||||
24. [Page map](#page-map)
|
||||
|
||||
---
|
||||
|
||||
@@ -393,6 +396,10 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
with event and section filters.
|
||||
- **Event tickets & printing** (`/dashboard/staff/event-tickets`) — list, search
|
||||
and print an event's tickets.
|
||||
- **Kids check-in** (`/dashboard/staff/kids`) and the **room screen**
|
||||
(`/dashboard/staff/kids/rooms`) — see [Kids check-in](#kids-check-in).
|
||||
- **Volunteers** (`/dashboard/staff/volunteers`) — today's roster, ticking volunteers in and the
|
||||
check-in QR code — see [Volunteer rosters](#volunteer-rosters).
|
||||
|
||||
---
|
||||
|
||||
@@ -511,6 +518,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 +531,112 @@ See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
|
||||
|
||||
---
|
||||
|
||||
## Multi-campus
|
||||
|
||||
For a church with several campuses in one install (Site settings → Campuses, off by
|
||||
default — single-campus churches never see campus fields).
|
||||
|
||||
- **Events** belong to one campus or to **All campuses** (church-wide). Choosing a campus fills in
|
||||
its default location. Moving an event to another campus is in the audit log.
|
||||
- **Public site:** a campus filter on the events page, remembered per visitor and starting on their
|
||||
home campus; a campus shows its own events plus church-wide ones. The campus shows on event cards,
|
||||
the event page and ticket PDFs. Each campus has a page at `/campus/<slug>` (also in the sitemap).
|
||||
- **People** choose a home campus on their profile.
|
||||
- **Reports and dashboards:** a campus filter on Reports (every report and the funnel) and on the
|
||||
admin/supervisor dashboards' month-over-month tiles, with a church-wide total.
|
||||
- **Messages:** "Everyone at a campus" in Email and WhatsApp broadcasts — home campus, plus (ticked)
|
||||
people who registered for its events in the last 12 months. News consent still applies.
|
||||
- **Daily summary:** campus recipients get the summary of their campus's events.
|
||||
- **Turning it off** hides everything campus-related; nothing is deleted. A campus with events can't
|
||||
be deleted, only archived.
|
||||
- **Staff for some campuses** (Admin → Users → "Limit to campuses"): a supervisor or staff member
|
||||
sees only their campuses' events plus events for all campuses — every list, report, dashboard
|
||||
and scanner follows it. For church-wide events they can scan, register people and take payments
|
||||
at the door, but not change, cash up or refund them. The site banner, payment reconciliation and
|
||||
donations not linked to an event are for admins and unscoped supervisors; broadcasts only reach
|
||||
people at their campuses or their events' attendees; coupons only for their campuses' events.
|
||||
Another campus's records answer "not found"; the scanner says which campus a ticket is for.
|
||||
Admins are never limited; limits are ignored while campuses are off.
|
||||
|
||||
---
|
||||
|
||||
## Kids check-in
|
||||
|
||||
Secure check-in and pickup for children's ministry, run from a staff desk and fully digital — no
|
||||
label printers.
|
||||
|
||||
- **Setup** (Supervisor → Kids check-in setup, `/dashboard/supervisor/kids`): per event, a
|
||||
**kids ticket** (a main ticket covering the whole event — "Add a free Kids check-in ticket"
|
||||
makes one) and **rooms** (an allocation set; each room has an age range, capacity and children
|
||||
per leader), then switch it on. Weekly kids church = one event with a session per Sunday.
|
||||
- **Families** are kept between events: parents add their children under My account → **Kids**
|
||||
(`/dashboard/user/kids`) — date of birth, allergies, medical and custody notes — and their
|
||||
**authorised adults** (name, phone, relationship; no account needed). The page shows a family QR
|
||||
code for the desk.
|
||||
- **Desk** (`/dashboard/staff/kids`): find the family (name, phone or family QR) or add one, tick
|
||||
the children (room suggested from the age: the narrowest range that isn't full), check in. The
|
||||
family gets a **4-character pickup code** for the day or session (no 0/O/1/I), sent to the parents
|
||||
by WhatsApp/email and shown on their Kids page with a QR code; it can also go to an authorised
|
||||
adult. A **"write on the sticker"** panel shows first name, room, code and ALLERGY/MEDICAL in
|
||||
large print to copy onto a blank sticker.
|
||||
- **Check-out:** type or scan the code, see the children on it, the parents, the authorised adults
|
||||
and any custody notes, record who collected. Anyone with the code can collect. A **wrong code**
|
||||
is refused, raises an alert on the room screens and is audited; **without the code** needs who
|
||||
and why (audited).
|
||||
- **Room screen** (`/dashboard/staff/kids/rooms`): live cards per room — code, allergies, medical
|
||||
and custody notes, parents and authorised adults — "I'm on duty", counts and full/over-ratio
|
||||
warnings, **Call parent** (WhatsApp/email, shown on the parent's page), move to another room,
|
||||
check out without code, wrong-code alerts.
|
||||
- **Offline desk:** "Get ready for offline" downloads the families and 20 pickup codes reserved for
|
||||
the device. Offline, the desk checks in (no messages) and checks out children it checked in
|
||||
itself; "Sync" uploads everything and says if a family got another code elsewhere.
|
||||
- **Attendance:** each child gets a free place on the kids ticket under the parent the first time
|
||||
(registration, attendee, ticket), and each check-in is a ticket usage — so the attendance
|
||||
register, reports and campus scoping include them.
|
||||
- **Privacy:** allergies, medical and custody notes encrypted, never in messages; custody notes
|
||||
changed only by parents, supervisors and admins (audited); opening the desk/room screens and
|
||||
offline downloads are in the access log; a child's profile is deleted after **6 months** without
|
||||
a check-in (Admin → Privacy → Retention).
|
||||
- **Volunteers in kids rooms:** a serving role linked to a room puts its volunteers on duty there
|
||||
when they check in; the room screen lists who is rostered but not in yet.
|
||||
- The kids ticket is never offered publicly (left out of public event pages, refused on public
|
||||
registration).
|
||||
|
||||
---
|
||||
|
||||
## Volunteer rosters
|
||||
|
||||
Who serves where, for an event or each session.
|
||||
|
||||
- **Roles** (Supervisor → Volunteer rosters, `/dashboard/supervisor/volunteers`): name, people
|
||||
needed, optional shift times (default: the session's or event's times), description, and
|
||||
optionally a kids room. On a multi-session event each role is for one session. A role can't be
|
||||
deleted once someone has checked in for it.
|
||||
- **Volunteering** (My account → Serving, `/dashboard/user/serving`): anyone with an account signs
|
||||
up for a role that isn't full; supervisors can also put people on (beyond the number needed).
|
||||
Nobody can be in two roles whose shifts overlap. `?event=<id>` opens one event (the sign-up link
|
||||
supervisors share, also for hidden events). Volunteers are told when they're put on, taken off,
|
||||
or a role is deleted, and get a **reminder the day before**.
|
||||
- **Swaps:** "Can't make it" asks for cover — the event's other volunteers are told and the request
|
||||
shows on everyone's Serving page; the first to accept takes the place at once (no approval). The
|
||||
event's notification recipients (or its creator) are emailed about every swap and withdrawal.
|
||||
- **Check-in:** volunteers scan the event's **check-in QR code** (Check-in QR tab, or Staff →
|
||||
Volunteers) with their phone camera → `/serve/check-in` → every shift of theirs on that day is
|
||||
checked in. Staff can tick people in by hand (and undo). Check-in opens on the day of the shift
|
||||
and closes when it ends.
|
||||
- **Kids rooms:** checking in for a role linked to a kids room puts the volunteer on duty in that
|
||||
room (counted for the ratio, like "I'm on duty"); undoing the check-in takes them off.
|
||||
- **Volunteer ticket** (off by default): choose a main ticket of the event, or add a free
|
||||
"Volunteer" ticket. Volunteers without a ticket get a free place on it (in My Events); it's
|
||||
cancelled when they leave the roster (unless it was scanned), moves with a swap, and is never
|
||||
offered publicly.
|
||||
- **Reports:** roster sheet (phone numbers, check-in time and how), printable; fill rate per event;
|
||||
**hours served** per person (checked-in shifts × shift length) over all events or one.
|
||||
- **Read-only licence:** new roles and sign-ups are blocked; check-ins, swaps and withdrawals keep
|
||||
working.
|
||||
|
||||
---
|
||||
|
||||
## Accounts & security
|
||||
|
||||
- Register, log in, forgot/reset password, activate account (for accounts
|
||||
@@ -590,7 +704,17 @@ Information Officer.
|
||||
answers, searchable.
|
||||
- **Access log** — every bulk export or view of personal information (reports, attendance
|
||||
registers, certificates, group lists, form answers, consent and medical lists, backups)
|
||||
and every "view as" session, with the number of records.
|
||||
and every "view as" session, with the number of records. Children's and medical
|
||||
information is flagged (medical lists, and anything about a youth event).
|
||||
- **Retention** — how long each kind of personal information is kept, applied by a nightly
|
||||
job: inactive accounts anonymised after 5 years (warning 30 days before; never staff
|
||||
roles or people with an upcoming registration), form answers deleted 12 months after
|
||||
the event, login history kept 24 months, payer details in payment notifications removed
|
||||
after 24 months, audit/access logs kept 5 years. The notice's `{retention}` placeholder
|
||||
lists them.
|
||||
- **Breaches** — the register of security compromises, with when the Information Regulator
|
||||
and the people affected were told, and drafts of both notifications.
|
||||
- **Readiness** — a scored checklist of what's set up, each item linking to its fix.
|
||||
|
||||
**For admins** — **Data requests** (`/dashboard/admin/data-requests`): correction and
|
||||
deletion requests with a 30-day due date, emailed to the Information Officer and
|
||||
@@ -629,8 +753,11 @@ can't start, the same jobs run on in-process timers.
|
||||
| `payment-status` — look up PayFast checkouts (10 min – 3 days old) whose notification never arrived, and record them | Every 15 minutes (`PAYMENT_STATUS_JOB_ENABLED=false`) |
|
||||
| `feedback` — post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false`) |
|
||||
| `sessions` — session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false`) |
|
||||
| `volunteers` — volunteer reminders the day before a shift | Every 15 minutes (`VOLUNTEER_JOBS_ENABLED=false`) |
|
||||
| `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 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 |
|
||||
@@ -639,6 +766,18 @@ can't start, the same jobs run on in-process timers.
|
||||
|
||||
## Operations
|
||||
|
||||
- **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`.
|
||||
- **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, 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
|
||||
one above 500 MB of memory.
|
||||
- **Sentry** error monitoring when `SENTRY_DSN` / `NEXT_PUBLIC_SENTRY_DSN` are
|
||||
@@ -650,11 +789,9 @@ can't start, the same jobs run on in-process timers.
|
||||
frontend against a separate, freshly seeded database: public pages, free and paid
|
||||
registration through to check-in and cashup, multi-day check-in, the attendance
|
||||
register and certificates, the event wizard, the kiosk, and the admin jobs page.
|
||||
- **CI** — `.gitea/workflows/ci.yml` runs the backend tests, frontend lint/typecheck/
|
||||
build and the end-to-end tests on every push (needs a Gitea Actions runner). A `v*`
|
||||
tag also deploys over SSH with `scripts/deploy.sh` once the `DEPLOY_*` secrets are set:
|
||||
it backs up the database, checks out the tag, migrates, builds and reloads PM2, and
|
||||
goes back to the previous version if a step fails before the reload.
|
||||
- **Deploy script** — `scripts/deploy.sh [tag]`, run on the server, backs up the
|
||||
database, checks out the release, migrates, builds and reloads PM2, and goes back to
|
||||
the previous version if a step fails before the reload.
|
||||
- Versioning via git tags and [CHANGELOG.md](./CHANGELOG.md).
|
||||
|
||||
---
|
||||
@@ -665,6 +802,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 |
|
||||
@@ -678,9 +816,10 @@ can't start, the same jobs run on in-process timers.
|
||||
| `/waitlist/claim/[token]` | Public | Claim a waitlist place |
|
||||
| `/certificates/verify/[code]` | Public | Check a completion certificate |
|
||||
| `/unsubscribe?t=…` | Public | Unsubscribe from news (link in news emails/WhatsApps) |
|
||||
| `/serve/check-in?code=…` | User (logs in first) | Volunteer check-in (where the event's check-in QR code leads) |
|
||||
| `/self-service` | Supervisor-unlocked | Kiosk |
|
||||
| `/set-banner` | Supervisor | Site banner |
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/privacy`, `/reset-password`) | User | My Events |
|
||||
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/attendance`, `/consent`, `/offline-scans`, `/allocations`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/privacy`, `/kids`, `/serving`, `/reset-password`) | User | My Events |
|
||||
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`, `/my-groups`, `/kids`, `/kids/rooms`, `/volunteers`) | Staff | Staff tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/attendance`, `/consent`, `/offline-scans`, `/allocations`, `/kids`, `/volunteers`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/data-requests`, `/privacy`, `/settings`) | Admin | Admin tools |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -92,12 +92,32 @@ Documentation: see Frontend README for environment and scripts.
|
||||
`E2E_DATABASE_URL` — which is dropped, recreated and seeded on every run. Your own dev
|
||||
servers and database aren't touched. The database user needs permission to create
|
||||
databases.
|
||||
- CI: `.gitea/workflows/ci.yml` runs all of the above on every push once a Gitea Actions
|
||||
runner is registered. Pushing a `v*` tag also deploys with `scripts/deploy.sh` when the
|
||||
`DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY` (and optional `DEPLOY_PATH`) secrets are set.
|
||||
- There's no CI: run these yourself, plus `npx eslint .` in `frontend/`, before tagging a
|
||||
release (the deploy's `next build` fails on any lint error). Releases are deployed by hand
|
||||
with `scripts/deploy.sh` (see [DEPLOYMENT.md](./DEPLOYMENT.md)).
|
||||
|
||||
## Deployment
|
||||
|
||||
### A new install
|
||||
|
||||
On a fresh Ubuntu 22.04/24.04 server, with the domain's DNS already pointing at it:
|
||||
|
||||
```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 --email you@yourchurch.org [--version v1.41.0]
|
||||
```
|
||||
|
||||
It installs Node 20, PostgreSQL (with its own database and a random password), PM2, nginx (the
|
||||
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 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:
|
||||
|
||||
- `bash scripts/deploy.sh` — the latest `main`
|
||||
|
||||
+22
@@ -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,6 +85,22 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
|
||||
| `consent_medical_retention_days` | `30` | Youth events: medical and emergency details from parental consent forms are deleted this many days after the event ends (daily 09:00 job). The consent record itself — who agreed, when, to which text — is kept. Set under Site Settings → Legal. |
|
||||
| `org_email` (as used on legal pages specifically) | `"privacy@example.com"` | Different fallback than `org_email`'s general default (blank) — also rendered as a `mailto:` link, so it needs to stay a valid-looking address. |
|
||||
|
||||
## Campuses
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `campuses_enabled` | `false` | Site settings → Campuses → "Use campuses". Changed only through `PUT /api/campuses/settings` (audited); hidden from the generic settings endpoints. Off hides campus fields and filters everywhere; campuses and each event's campus are kept. The campuses themselves are in the `Campus` table. |
|
||||
|
||||
## Cross Events Hub (licence)
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `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 (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
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
@@ -97,6 +114,11 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
|
||||
|---|---|---|
|
||||
| `legal_io_email` (Information Officer, Site Settings → Legal) | — | Also where new privacy requests and the daily "requests due" reminder are emailed, together with all admins. The `io@example.com` placeholder is skipped. |
|
||||
| `PRIVACY_JOBS_ENABLED` (env) | `true` | The daily 08:00 `privacy` job that reminds admins of requests due within a week or overdue. |
|
||||
| `retention_policies` | Inactive accounts 60 months (on), form answers 12, login history 24, payment details 24, children at kids check-in (`kids_profiles`) 6, audit/access logs 60 — all on | Admin → Privacy → Retention (JSON `{ policies: { key: { enabled, months } }, reviewedAt, reviewedBy }`). Minimums: inactive accounts and logs 12 months, login history and payment details 3, form answers and kids profiles 1; maximum 240. Saving marks them reviewed (readiness check). |
|
||||
| `retention_last_run` | — | When the `retention` job last ran and what it did (readiness check: within 2 days). |
|
||||
| `VOLUNTEER_JOBS_ENABLED` (env) | `true` | The `volunteers` job (every 15 minutes) that sends volunteer reminders the day before a shift. |
|
||||
| `RETENTION_JOBS_ENABLED` (env) | `true` | The nightly 03:30 `retention` job. Off means nothing expires automatically. |
|
||||
| `wawp_webhook_last_seen` | — | Set when WAWP's webhook arrives with the right secret; the readiness page uses it to check WhatsApp STOP is set up. |
|
||||
| `privacy_notice_draft` | — | The unpublished draft from Admin → Privacy → Privacy notice (JSON). Published versions are in the `PrivacyNotice` table. With no published version the built-in notice (`backend/src/utils/privacyNotice.js`) is shown. The notice's org name, Information Officer, website, emails and medical retention days come from the settings above when it's shown. |
|
||||
|
||||
People's News & announcements opt-in is per account (`User.newsEmail`, `User.newsWhatsApp`), not a site setting. Unsubscribe links are signed with `JWT_SECRET` and don't expire — changing `JWT_SECRET` breaks links in emails already sent.
|
||||
|
||||
@@ -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)
|
||||
@@ -71,8 +75,20 @@ WAITLIST_JOB_ENABLED=true
|
||||
# WAITLIST_JOB_INTERVAL_MS=600000
|
||||
# Payments: every 15 minutes, look up PayFast checkouts whose notification never arrived
|
||||
PAYMENT_STATUS_JOB_ENABLED=true
|
||||
# Volunteer rosters: reminders the day before a shift (every 15 minutes)
|
||||
VOLUNTEER_JOBS_ENABLED=true
|
||||
# Privacy (POPIA): daily 08:00 reminder to admins of data requests due within a week
|
||||
PRIVACY_JOBS_ENABLED=true
|
||||
# Privacy (POPIA): nightly 03:30 retention job (Admin → Privacy → Retention). Note: inactive
|
||||
# accounts are anonymised after 5 years by default (with a warning 30 days before).
|
||||
RETENTION_JOBS_ENABLED=true
|
||||
|
||||
# ─── 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).
|
||||
# HUB_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----"
|
||||
HUB_HEARTBEAT_ENABLED=true
|
||||
# Nightly database backup at 02:00 (requires the `pg_dump` binary on this host).
|
||||
# Retention count is set via Admin → Site Settings → Backups (default 14).
|
||||
BACKUP_ENABLED=true
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -44,6 +44,7 @@ Tokens are issued by `POST /api/users/login`.
|
||||
- [Forms](#forms)
|
||||
- [Provider Transactions](#provider-transactions)
|
||||
- [WhatsApp Admin](#whatsapp-admin)
|
||||
- [Hub and licence](#hub-and-licence)
|
||||
- [Webhooks](#webhooks)
|
||||
- [Settings](#settings)
|
||||
- [Setup](#setup)
|
||||
@@ -582,8 +583,16 @@ Privacy notice, operators and logs (`src/utils/privacyNotice.js`, `src/utils/acc
|
||||
| PUT / DELETE | `/api/privacy/operators/:id` | admin | Change / remove an operator (audited as `operator_*`) |
|
||||
| GET | `/api/privacy/consent-log?type=news\|notice\|parental&q=&page=` | admin | News opt-in changes, notice acceptances, or parental consent answers (50 per page) |
|
||||
| GET | `/api/privacy/access-log?action=&actorId=&from=&to=&page=` | admin | Bulk exports/views of personal information, with `actions` (labels) and `actors` for the filters |
|
||||
| GET | `/api/privacy/readiness` | admin | `{ score, passed, total, checks: [{ key, label, ok, detail, href, applies }] }` |
|
||||
| GET | `/api/privacy/retention` | admin | `{ policies, definitions, reviewedAt, reviewedBy, preview, lastRun, related }` — `preview` is what the next run would affect |
|
||||
| PUT | `/api/privacy/retention` | admin | `{ policies: { inactive_accounts\|form_answers\|login_history\|payment_details\|audit_logs: { enabled, months } } }` — all five required. Audited as `retention_policies_updated` |
|
||||
| POST | `/api/privacy/retention/run` | admin | Run the retention job now: `{ result, state }` |
|
||||
| GET / POST | `/api/privacy/breaches` | admin | The breach register / record one `{ title, discoveredAt, description, dataAffected, occurredAt?, peopleAffected?, whoAffected?, consequences?, actionsTaken?, adviceToPeople?, causedBy? }` |
|
||||
| PUT | `/api/privacy/breaches/:id` | admin | Change any of those, `status` (open\|contained\|closed), `regulatorNotifiedAt`, `peopleNotifiedAt`. Audited as `breach_updated` |
|
||||
|
||||
Placeholders in notice sections: `{org}`, `{orgEmail}`, `{website}`, `{ioName}`, `{ioEmail}`, `{medicalRetentionDays}`, and `{operators}` on a line of its own (one bullet per active operator). Login and `GET /api/users/profile` return `noticePromptPending` — true when a version with `requiresAcceptance` is newer than the one the person accepted. Sign-up records an acceptance of the current version (source `signup`).
|
||||
The `retention` job (03:30, `RETENTION_JOBS_ENABLED=false` to disable) works through each switched-on policy. Inactive accounts: role `user`, not anonymised, whose latest of created / last login (`User.lastLoginAt`) / registration or its event's end / named as attendee / payment is older than the period. Those within 30 days of it are warned (email, else WhatsApp; `User.inactivityWarnedAt`), and those warned at least 30 days ago — after their last activity — are anonymised unless they have a registration for an upcoming event. Logging in clears the warning. At most 300 warnings and 200 anonymisations per run. Payment details: `ProviderTransaction.raw` and `ProviderCheckout.request` older than the period are scrubbed and marked `_scrubbed`. Access log rows carry `sensitive` (medical lists, and anything for an event with `requiresParentalConsent`); `?sensitive=true` filters them.
|
||||
|
||||
Placeholders in notice sections: `{org}`, `{orgEmail}`, `{website}`, `{ioName}`, `{ioEmail}`, `{medicalRetentionDays}`, and — each on a line of its own — `{operators}` (one bullet per active operator) and `{retention}` (one bullet per retention period). Login and `GET /api/users/profile` return `noticePromptPending` — true when a version with `requiresAcceptance` is newer than the one the person accepted. Sign-up records an acceptance of the current version (source `signup`).
|
||||
|
||||
Access log actions (`DataAccessLog.action`): `report_pdf`, `report_excel`, `report_email`, `report_whatsapp` (`/api/reports/*`, with the row count), `attendance_export`, `certificates_download`, `group_lists` (or `medical_list` with `?medical=true`), `form_responses` (first page of a list), `consent_list`, `medical_list`, `backup_download`, `impersonation`.
|
||||
|
||||
@@ -828,6 +837,126 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
|
||||
|
||||
---
|
||||
|
||||
## Hub and licence
|
||||
|
||||
The connection to the Cross Events Hub (`src/hub/`, protocol in `docs/hub-protocol.md`).
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/health` | public | `{ status, version, db, jobs, lastBackupAt }` — 503 when the database is down |
|
||||
| GET | `/api/hub/status` | admin | `{ state: active\|grace\|suspended\|revoked\|unlicensed, readOnly, daysLeft, endsAt, graceEndsAt, licence, connected, keyConfigured, hubUrl, lastHeartbeatAt, lastError, version, latestVersion, updateAvailable }` |
|
||||
| POST | `/api/hub/connect` | admin (incl. the `/setup` token) | `{ licenceKey }` — registers with the Hub; stores the install id, encrypted secret and licence token. Hub errors pass through with their `code` (`LICENCE_NOT_FOUND`, `LICENCE_IN_USE`, `LICENCE_REVOKED`, `HUB_UNREACHABLE`, …). Audited as `hub_connected` |
|
||||
| POST | `/api/hub/heartbeat` | admin | Check in now; returns the status |
|
||||
| POST | `/api/hub/update-request` | admin | Ask the Hub for this site to be updated (protocol §3a); the provider is told, nothing runs on the site. Returns the status with `updateRequest: { requestedAt, version }` until the site runs that version. `409 UP_TO_DATE`; audited as `hub_update_requested` |
|
||||
|
||||
**Read-only mode** (licence suspended, revoked, or past its grace period): `403 { code: "LICENCE_READ_ONLY" }` on `POST /api/events`, event options and variants, `POST /api/registrations` (and `/manual`), `PUT /api/registrations/:id/options`, `POST /api/payments`, `/payments/checkout` (`/send`), `/payments/yoco-checkout` (`/send`), `/payments/pay/:id`, `POST /api/waitlist`, `POST /api/coupons`, `POST /api/bundles/event/:id`. Everything else — scanning, exports, refunds, cancellations, privacy requests, settings — keeps working, and payment webhooks for payments already started are still processed.
|
||||
|
||||
## Campuses
|
||||
|
||||
Multi-campus (`src/utils/campuses.js`), off by default. While off, every campus field and filter
|
||||
below is ignored and `GET /api/campuses` returns `{ enabled: false, campuses: [] }`.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/campuses` | public | `{ enabled, campuses }` — active campuses in order |
|
||||
| GET | `/api/campuses/by-slug/:slug` | public | One active campus (its `/campus/<slug>` page); 404 when archived or off |
|
||||
| GET | `/api/campuses/admin` | admin | All campuses incl. archived, with `notifyRecipients` and `_count { events, homeOf }` |
|
||||
| PUT | `/api/campuses/settings` | admin | `{ enabled }` — audited as `campuses_toggled` (the `campuses_enabled` setting can't be changed through `PUT /api/settings`) |
|
||||
| POST | `/api/campuses` | admin | `{ name, slug?, address?, contactName?, contactEmail?, contactPhone?, colour? (#rrggbb), logoUrl?, defaultLocation? }`; 409 when the slug is taken |
|
||||
| PUT | `/api/campuses/:id` | admin | Same fields, plus `notifyRecipientIds` (staff only) and `archived` |
|
||||
| PUT | `/api/campuses/order` | admin | `{ ids: [...] }` |
|
||||
| DELETE | `/api/campuses/:id` | admin | Only without events — otherwise `409 CAMPUS_HAS_EVENTS` (archive instead) |
|
||||
| GET | `/api/campuses/:id/audience` | supervisor+ | `{ campus, users }` — home campus, plus `?registrants=true` people registered for its events in the last 12 months (not cancelled). Broadcasts send to these ids as usual, so news consent still applies |
|
||||
|
||||
**Campus-scoped staff (1.44.0):** a supervisor or staff member with campus limits
|
||||
(`PUT /api/users/:id { scopedCampusIds }`, admin; `GET /api/users/:id` returns `scopedCampuses`;
|
||||
`GET /api/users/profile` returns `campusScope`, null when unscoped or campuses are off) only sees
|
||||
their campuses' events plus church-wide ones on every staff/supervisor endpoint
|
||||
(`src/utils/campusScope.js`). Responses for out-of-scope records:
|
||||
- `404 { message: "Not found" }` — another campus's event or record (nothing leaks).
|
||||
- `403 { code: "OTHER_CAMPUS" }` — the scanner (`/api/tickets/scan-preview|qr|scan/:qrCode`), naming the ticket's campus.
|
||||
- `403 { code: "CHURCH_WIDE_EVENT" }` — changing, cashing up or refunding an event for all campuses.
|
||||
- `403 { code: "CAMPUS_SCOPED" }` — banner, payment reconciliation, open checkouts, unlinked donations, typed-in broadcast addresses or recipients outside their campuses.
|
||||
- `409 { code: "CAMPUS_HAS_SCOPED_STAFF" }` on `DELETE /api/campuses/:id` while staff are limited to it.
|
||||
|
||||
**Campus on other endpoints:**
|
||||
- Events carry `campusId` (null = all campuses) and `campus { id, name, slug, colour }`. `POST`/`PUT /api/events` take `campusId` (`''`/`null` = all campuses; an archived campus can stay but can't be newly chosen); moving an event is audited as `event_campus_changed`.
|
||||
- `GET /api/events?campus=<slug|id>` — that campus's events plus church-wide ones. `GET /api/events/all?campus=<id|slug|all>` — one campus, or church-wide only.
|
||||
- `GET /api/stats/overview?campus=<id|slug|all>`.
|
||||
- `PUT /api/users/profile` takes `homeCampusId`; `GET` returns it.
|
||||
|
||||
## Kids check-in
|
||||
|
||||
Kids check-in (1.45.0, `src/utils/kids.js`). An event has it when `kidsCheckIn` is true with a
|
||||
`kidsOptionId` (main ticket covering the whole event) and `kidsRoomSetId` (allocation set whose
|
||||
groups are the rooms; groups take `minAge`, `maxAge`, `ratio` on `POST /api/allocations/sets/:id/groups`
|
||||
and `PUT /api/allocations/groups/:id`). The **occasion** is today's session on a multi-session event,
|
||||
or the day (`YYYY-MM-DD`, `APP_TIMEZONE`) on a single-date one; pickup codes are per family per occasion.
|
||||
Allergies, medical and custody notes are stored encrypted. Staff routes are campus-scoped.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/kids/me` | user | `{ family, qrToken, codes, pages }` — my family (or null), live pickup codes (`{ code, qr, event, children }`) and pages from the last 24 h |
|
||||
| POST / PUT / DELETE | `/api/kids/me` | user | Start my family `{ name }` / rename it / leave it (the last parent leaving deletes it and its children) |
|
||||
| POST, PUT, DELETE | `/api/kids/me/children[/:id]` | user | `{ firstName, lastName?, dateOfBirth?, gender?, allergies?, medicalNotes?, custodyNotes? }`; custody changes audited as `kids_custody_notes_changed` |
|
||||
| POST, PUT, DELETE | `/api/kids/me/adults[/:id]` | user | Authorised adults `{ name, phone?, relationship? }` (up to 10) |
|
||||
| GET | `/api/kids/setup-events` | supervisor+ | Upcoming events for the setup page |
|
||||
| GET / PUT | `/api/kids/events/:eventId/settings` | supervisor+ | `{ enabled?, optionId?, roomSetId? }` — `'new'` makes a free "Kids check-in" main ticket or a "Kids rooms" set; switching on needs both. Audited as `kids_settings_updated`; PUT needs a campus the supervisor manages |
|
||||
| GET | `/api/kids/events` | staff+ | Events with kids check-in, from the last week on, with their sessions |
|
||||
| GET | `/api/kids/events/:eventId/board?sessionId=&log=1` | staff+ | `{ event, occasion, sessionId, rooms, checkIns, alerts }`. Rooms carry `children`, `leaders`, `leadersNeeded`, `full`, `overRatio`; check-ins carry `code`, allergies, medical and custody notes, the family's parents and adults, and `sticker`. `log=1` writes the access log (`kids_rooms`) |
|
||||
| GET | `/api/kids/events/:eventId/families?q=` | staff+ | Families by family, child or parent name, email or phone (min. 2 characters) |
|
||||
| POST | `/api/kids/events/:eventId/families` | staff+ | `{ name?, parent: { name, phone?, email? }, children? }` — the parent's account is matched by email/phone or created inactive; an existing family is reused |
|
||||
| GET | `/api/kids/events/:eventId/families/qr/:token` | staff+ | The family for a family QR (`FAMILY-<token>`) |
|
||||
| GET | `/api/kids/events/:eventId/families/:familyId` | staff+ | One family |
|
||||
| POST | `/api/kids/events/:eventId/families/:familyId/parents` | staff+ | Add a parent (409 if they're in another family) |
|
||||
| POST, PUT | `/api/kids/events/:eventId/families/:familyId/children[/:childId]` | staff+ | Add/change a child — custody notes only from supervisors and admins |
|
||||
| POST | `/api/kids/events/:eventId/families/:familyId/adults` | staff+ | Add an authorised adult |
|
||||
| POST | `/api/kids/events/:eventId/check-in` | staff+ | `{ familyId, sessionId?, children: [{ childId, roomId? }], sendToAdultId? }` → `201 { code, qr, occasion, family, checkIns: [{ id, name, firstName, room, already, sticker }] }`. Messages the parents (and the adult) |
|
||||
| GET | `/api/kids/events/:eventId/code/:code?sessionId=` | staff+ | `{ code, found, family, children }` for a pickup code (`KIDS-` prefix and case ignored) |
|
||||
| POST | `/api/kids/events/:eventId/check-out` | staff+ | `{ code, checkInIds?, collectedBy, roomId?, sessionId? }` → `{ checkedOut }`. `404 WRONG_CODE` (alert + audit `kids_wrong_code`) or `404 NOTHING_TO_COLLECT` |
|
||||
| POST | `/api/kids/check-ins/:id/override` | staff+ | `{ collectedBy, reason }` — check out without the code; audited as `kids_checkout_override` |
|
||||
| POST | `/api/kids/check-ins/:id/page` | staff+ | `{ message? }` → `{ sent: ['whatsapp'\|'email'], text }` — "Please come to the … room" |
|
||||
| PUT | `/api/kids/check-ins/:id/room` | staff+ | `{ roomId }` |
|
||||
| POST | `/api/kids/events/:eventId/duty` | staff+ | `{ roomId, on, sessionId? }` — "I'm on duty" (one room at a time) |
|
||||
| POST | `/api/kids/alerts/:id/resolve` | staff+ | Mark an alert dealt with |
|
||||
| GET | `/api/kids/events/:eventId/offline?deviceId=&sessionId=` | staff+ | Offline snapshot: board plus `families` and `codes` (20 reserved for the device). Access log `kids_offline` |
|
||||
| POST | `/api/kids/events/:eventId/sync` | staff+ | `{ deviceId, occasion, checkIns: [{ clientId, familyId, childId, roomId, code, at }], checkOuts: [{ clientId, collectedBy, at }] }` → `{ checkedIn, checkedOut, conflicts }`; idempotent by `clientId`; audited as `kids_offline_sync` |
|
||||
|
||||
Since 1.46.0 board rooms also carry `rostered: [{ userId, name, checkedIn }]` (volunteers on a serving
|
||||
role linked to the room); checked-in ones are counted in `leaders`.
|
||||
|
||||
## Volunteer rosters
|
||||
|
||||
Volunteer rosters (1.46.0, `src/utils/volunteers.js`). A **serving role** belongs to an event, or to
|
||||
one session of a multi-session event (`sessionId` required there), with `needed` people and optional
|
||||
`startsAt`/`endsAt` (default: the session's or event's times). A **slot** is a person on a role;
|
||||
nobody can hold two slots whose shifts overlap (`409 SHIFT_CLASH`). When `Event.volunteerOptionId`
|
||||
is set, each volunteer without a ticket gets a free place on it (cancelled when they leave, moved
|
||||
with a swap). `volunteerOptionId` and `kidsOptionId` are left out of public event pages
|
||||
(`GET /api/events`, `GET /api/events/:id` for non-staff) and refused on public registration. Staff
|
||||
routes are campus-scoped.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/volunteers/me` | user | `{ slots, cover, events }` — my shifts (upcoming and the last week; `canCheckIn`, `swapId`, `hasTicket`), open cover requests I could take, and public events with roles |
|
||||
| GET | `/api/volunteers/events/:eventId` | user | `{ event, sessions, roles, totals }` — each role with `shift`, `needed`, `filled`, `full`, `mine`, `coverNeeded` (no names) |
|
||||
| POST | `/api/volunteers/roles/:id/sign-up` | user | `201 { slotId, ticket }`; `409 ROLE_FULL`, `409 SHIFT_CLASH`. Blocked in licence read-only mode |
|
||||
| DELETE | `/api/volunteers/slots/:id` | user | Withdraw (not after check-in or once the shift is over); the event's notify recipients are emailed |
|
||||
| POST | `/api/volunteers/slots/:id/swap` | user | `{ note? }` → `{ swapId }` — "can't make it"; the event's other volunteers are told |
|
||||
| DELETE | `/api/volunteers/swaps/:id` | user | Cancel my cover request |
|
||||
| POST | `/api/volunteers/swaps/:id/accept` | user | Take the place over at once → `{ slotId, role, event }`; `409 SWAP_CLOSED` if someone was first. Both volunteers and the event's notify recipients are told |
|
||||
| POST | `/api/volunteers/check-in` | user | `{ qr }` (`SERVE-<token>`) → `{ event, checkedIn: [{ role, kidsRoom }], already }` for my shifts on today. `404 BAD_QR`, `404 NOT_ROSTERED`, `400 NOT_TODAY`. A kids-room role starts a `KidsRoomDuty` (`fromRoster`) |
|
||||
| GET | `/api/volunteers/manage/events?all=1` | staff+ | Events with roles from the last 30 days on; `all=1` (supervisors) every upcoming event |
|
||||
| GET | `/api/volunteers/manage/events/:eventId?log=1` | staff+ | The roster with `volunteers: [{ slotId, name, phone, email, checkedInAt, checkInMethod, swapOpen, hasTicket }]` per role, and `totals: { needed, filled, checkedIn, fillRate }`. `log=1` writes the access log (`volunteer_roster`) |
|
||||
| GET | `/api/volunteers/manage/events/:eventId/qr` | staff+ | `{ token, qr }` — the check-in QR value (the page encodes `/serve/check-in?code=<qr>`) |
|
||||
| POST | `/api/volunteers/manage/slots/:id/check-in` | staff+ | `{ undo? }` — check in by hand (or undo, which ends rostered kids-room duty). Audited as `volunteer_checked_in_manual` |
|
||||
| GET / PUT | `/api/volunteers/manage/events/:eventId/settings` | supervisor+ | `{ optionId }` — the volunteer ticket: a main ticket covering the whole event, `'new'` for a free "Volunteer" ticket, `null` = off (default). Also returns the event's `kidsRooms`. Audited as `volunteer_settings_updated` |
|
||||
| POST | `/api/volunteers/manage/events/:eventId/roles` | supervisor+ | `{ name, needed, sessionId?, startsAt?, endsAt?, description?, kidsRoomId? }`. Audited as `volunteer_roles_changed`; blocked in read-only mode |
|
||||
| PUT / DELETE | `/api/volunteers/manage/roles/:id` | supervisor+ | Change / delete a role (delete refused once someone checked in; its volunteers are told) |
|
||||
| POST | `/api/volunteers/manage/roles/:id/assign` | supervisor+ | `{ userId }` — allowed beyond `needed`; the person is told. Audited as `volunteer_assigned` |
|
||||
| DELETE | `/api/volunteers/manage/slots/:id` | supervisor+ | Take someone off (not after check-in); they're told. Audited as `volunteer_removed` |
|
||||
| GET | `/api/volunteers/manage/hours?eventId=&from=&to=` | supervisor+ | `[{ userId, name, shifts, hours, events }]` — checked-in shifts × shift length, most first |
|
||||
|
||||
## Webhooks
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|
||||
+2
-2
@@ -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`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.39.0",
|
||||
"version": "1.46.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.39.0",
|
||||
"version": "1.46.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.39.0",
|
||||
"version": "1.46.1",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BreachStatus" AS ENUM ('open', 'contained', 'closed');
|
||||
|
||||
-- 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 'retention_policies_updated';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'retention_run';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'breach_recorded';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'breach_updated';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "DataAccessLog" ADD COLUMN "sensitive" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "inactivityWarnedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "lastLoginAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SecurityBreach" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"status" "BreachStatus" NOT NULL DEFAULT 'open',
|
||||
"discoveredAt" TIMESTAMP(3) NOT NULL,
|
||||
"occurredAt" TIMESTAMP(3),
|
||||
"description" TEXT NOT NULL,
|
||||
"dataAffected" TEXT NOT NULL,
|
||||
"peopleAffected" INTEGER,
|
||||
"whoAffected" TEXT,
|
||||
"consequences" TEXT,
|
||||
"actionsTaken" TEXT,
|
||||
"adviceToPeople" TEXT,
|
||||
"causedBy" TEXT,
|
||||
"regulatorNotifiedAt" TIMESTAMP(3),
|
||||
"peopleNotifiedAt" TIMESTAMP(3),
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SecurityBreach_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SecurityBreach_status_discoveredAt_idx" ON "SecurityBreach"("status", "discoveredAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SecurityBreach" ADD CONSTRAINT "SecurityBreach_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Last login, from the login history (which the retention job will start expiring).
|
||||
UPDATE "User" u SET "lastLoginAt" = l.at
|
||||
FROM (SELECT "userId", MAX("createdAt") AS at FROM "SecurityEvent" WHERE "type" = 'login' AND "userId" IS NOT NULL GROUP BY "userId") l
|
||||
WHERE u."id" = l."userId";
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'hub_connected';
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'hub_update_requested';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'campuses_toggled';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'campus_created';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'campus_updated';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'campus_archived';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'campus_deleted';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'event_campus_changed';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "campusId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "homeCampusId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Campus" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"address" TEXT,
|
||||
"contactName" TEXT,
|
||||
"contactEmail" TEXT,
|
||||
"contactPhone" TEXT,
|
||||
"colour" TEXT,
|
||||
"logoUrl" TEXT,
|
||||
"defaultLocation" TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"archivedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Campus_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_CampusNotifyRecipients" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "_CampusNotifyRecipients_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Campus_slug_key" ON "Campus"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Campus_order_idx" ON "Campus"("order");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_CampusNotifyRecipients_B_index" ON "_CampusNotifyRecipients"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Event_campusId_idx" ON "Event"("campusId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "User_homeCampusId_idx" ON "User"("homeCampusId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "User" ADD CONSTRAINT "User_homeCampusId_fkey" FOREIGN KEY ("homeCampusId") REFERENCES "Campus"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Event" ADD CONSTRAINT "Event_campusId_fkey" FOREIGN KEY ("campusId") REFERENCES "Campus"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_CampusNotifyRecipients" ADD CONSTRAINT "_CampusNotifyRecipients_A_fkey" FOREIGN KEY ("A") REFERENCES "Campus"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_CampusNotifyRecipients" ADD CONSTRAINT "_CampusNotifyRecipients_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'user_campus_scope_changed';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_UserCampusScope" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "_UserCampusScope_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_UserCampusScope_B_index" ON "_UserCampusScope"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_UserCampusScope" ADD CONSTRAINT "_UserCampusScope_A_fkey" FOREIGN KEY ("A") REFERENCES "Campus"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_UserCampusScope" ADD CONSTRAINT "_UserCampusScope_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,250 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "KidsAlertKind" AS ENUM ('wrong_code', 'page_parent', 'override');
|
||||
|
||||
-- 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 'kids_settings_updated';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'kids_checkout_override';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'kids_wrong_code';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'kids_custody_notes_changed';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'kids_offline_sync';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "AllocationGroup" ADD COLUMN "maxAge" INTEGER,
|
||||
ADD COLUMN "minAge" INTEGER,
|
||||
ADD COLUMN "ratio" INTEGER;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "kidsCheckIn" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "kidsOptionId" TEXT,
|
||||
ADD COLUMN "kidsRoomSetId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Family" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"qrToken" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Family_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FamilyMember" (
|
||||
"id" TEXT NOT NULL,
|
||||
"familyId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "FamilyMember_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Child" (
|
||||
"id" TEXT NOT NULL,
|
||||
"familyId" TEXT NOT NULL,
|
||||
"firstName" TEXT NOT NULL,
|
||||
"lastName" TEXT,
|
||||
"dateOfBirth" TIMESTAMP(3),
|
||||
"gender" TEXT,
|
||||
"allergies" TEXT,
|
||||
"medicalNotes" TEXT,
|
||||
"custodyNotes" TEXT,
|
||||
"lastCheckInAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Child_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuthorisedAdult" (
|
||||
"id" TEXT NOT NULL,
|
||||
"familyId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"phone" TEXT,
|
||||
"relationship" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AuthorisedAdult_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "KidsPickupCode" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"occasion" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"familyId" TEXT,
|
||||
"deviceId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "KidsPickupCode_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "KidsCheckIn" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"occasion" TEXT NOT NULL,
|
||||
"sessionId" TEXT,
|
||||
"childId" TEXT,
|
||||
"childName" TEXT NOT NULL,
|
||||
"roomId" TEXT,
|
||||
"pickupCodeId" TEXT NOT NULL,
|
||||
"attendeeId" TEXT,
|
||||
"ticketId" TEXT,
|
||||
"checkedInAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"checkedInById" TEXT,
|
||||
"checkedOutAt" TIMESTAMP(3),
|
||||
"checkedOutById" TEXT,
|
||||
"collectedBy" TEXT,
|
||||
"overrideReason" TEXT,
|
||||
"clientId" TEXT,
|
||||
"offline" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "KidsCheckIn_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "KidsAlert" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"occasion" TEXT NOT NULL,
|
||||
"kind" "KidsAlertKind" NOT NULL,
|
||||
"roomId" TEXT,
|
||||
"checkInId" TEXT,
|
||||
"message" TEXT NOT NULL,
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"resolvedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "KidsAlert_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "KidsRoomDuty" (
|
||||
"id" TEXT NOT NULL,
|
||||
"roomId" TEXT NOT NULL,
|
||||
"occasion" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"endedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "KidsRoomDuty_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Family_qrToken_key" ON "Family"("qrToken");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "FamilyMember_userId_key" ON "FamilyMember"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FamilyMember_familyId_idx" ON "FamilyMember"("familyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Child_familyId_idx" ON "Child"("familyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Child_lastCheckInAt_idx" ON "Child"("lastCheckInAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuthorisedAdult_familyId_idx" ON "AuthorisedAdult"("familyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KidsPickupCode_eventId_occasion_deviceId_idx" ON "KidsPickupCode"("eventId", "occasion", "deviceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "KidsPickupCode_eventId_occasion_code_key" ON "KidsPickupCode"("eventId", "occasion", "code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "KidsPickupCode_eventId_occasion_familyId_key" ON "KidsPickupCode"("eventId", "occasion", "familyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "KidsCheckIn_clientId_key" ON "KidsCheckIn"("clientId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KidsCheckIn_eventId_occasion_idx" ON "KidsCheckIn"("eventId", "occasion");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KidsCheckIn_roomId_idx" ON "KidsCheckIn"("roomId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KidsCheckIn_pickupCodeId_idx" ON "KidsCheckIn"("pickupCodeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "KidsCheckIn_eventId_occasion_childId_key" ON "KidsCheckIn"("eventId", "occasion", "childId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KidsAlert_eventId_occasion_createdAt_idx" ON "KidsAlert"("eventId", "occasion", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KidsAlert_checkInId_idx" ON "KidsAlert"("checkInId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KidsRoomDuty_roomId_occasion_idx" ON "KidsRoomDuty"("roomId", "occasion");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KidsRoomDuty_userId_idx" ON "KidsRoomDuty"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FamilyMember" ADD CONSTRAINT "FamilyMember_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FamilyMember" ADD CONSTRAINT "FamilyMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Child" ADD CONSTRAINT "Child_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AuthorisedAdult" ADD CONSTRAINT "AuthorisedAdult_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsPickupCode" ADD CONSTRAINT "KidsPickupCode_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsPickupCode" ADD CONSTRAINT "KidsPickupCode_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsCheckIn" ADD CONSTRAINT "KidsCheckIn_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsCheckIn" ADD CONSTRAINT "KidsCheckIn_childId_fkey" FOREIGN KEY ("childId") REFERENCES "Child"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsCheckIn" ADD CONSTRAINT "KidsCheckIn_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "AllocationGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsCheckIn" ADD CONSTRAINT "KidsCheckIn_pickupCodeId_fkey" FOREIGN KEY ("pickupCodeId") REFERENCES "KidsPickupCode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsCheckIn" ADD CONSTRAINT "KidsCheckIn_checkedInById_fkey" FOREIGN KEY ("checkedInById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsCheckIn" ADD CONSTRAINT "KidsCheckIn_checkedOutById_fkey" FOREIGN KEY ("checkedOutById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsAlert" ADD CONSTRAINT "KidsAlert_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsAlert" ADD CONSTRAINT "KidsAlert_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "AllocationGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsAlert" ADD CONSTRAINT "KidsAlert_checkInId_fkey" FOREIGN KEY ("checkInId") REFERENCES "KidsCheckIn"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsAlert" ADD CONSTRAINT "KidsAlert_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsRoomDuty" ADD CONSTRAINT "KidsRoomDuty_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "AllocationGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsRoomDuty" ADD CONSTRAINT "KidsRoomDuty_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,133 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "VolunteerSwapStatus" AS ENUM ('open', 'accepted', 'cancelled');
|
||||
|
||||
-- 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 'volunteer_settings_updated';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'volunteer_roles_changed';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'volunteer_assigned';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'volunteer_removed';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'volunteer_checked_in_manual';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "volunteerCheckInToken" TEXT,
|
||||
ADD COLUMN "volunteerOptionId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "KidsRoomDuty" ADD COLUMN "fromRoster" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ServingRole" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"sessionId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"needed" INTEGER NOT NULL DEFAULT 1,
|
||||
"startsAt" TIMESTAMP(3),
|
||||
"endsAt" TIMESTAMP(3),
|
||||
"kidsRoomId" TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ServingRole_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VolunteerSlot" (
|
||||
"id" TEXT NOT NULL,
|
||||
"roleId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"assignedById" TEXT,
|
||||
"checkedInAt" TIMESTAMP(3),
|
||||
"checkedInById" TEXT,
|
||||
"checkInMethod" TEXT,
|
||||
"remindedAt" TIMESTAMP(3),
|
||||
"registrationId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "VolunteerSlot_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VolunteerSwap" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slotId" TEXT,
|
||||
"roleId" TEXT NOT NULL,
|
||||
"fromUserId" TEXT,
|
||||
"toUserId" TEXT,
|
||||
"note" TEXT,
|
||||
"status" "VolunteerSwapStatus" NOT NULL DEFAULT 'open',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"resolvedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "VolunteerSwap_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ServingRole_eventId_sessionId_idx" ON "ServingRole"("eventId", "sessionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ServingRole_kidsRoomId_idx" ON "ServingRole"("kidsRoomId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VolunteerSlot_userId_idx" ON "VolunteerSlot"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VolunteerSlot_registrationId_idx" ON "VolunteerSlot"("registrationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VolunteerSlot_roleId_userId_key" ON "VolunteerSlot"("roleId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VolunteerSwap_roleId_status_idx" ON "VolunteerSwap"("roleId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VolunteerSwap_slotId_idx" ON "VolunteerSwap"("slotId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Event_volunteerCheckInToken_key" ON "Event"("volunteerCheckInToken");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServingRole" ADD CONSTRAINT "ServingRole_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServingRole" ADD CONSTRAINT "ServingRole_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "EventSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServingRole" ADD CONSTRAINT "ServingRole_kidsRoomId_fkey" FOREIGN KEY ("kidsRoomId") REFERENCES "AllocationGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSlot" ADD CONSTRAINT "VolunteerSlot_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "ServingRole"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSlot" ADD CONSTRAINT "VolunteerSlot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSlot" ADD CONSTRAINT "VolunteerSlot_assignedById_fkey" FOREIGN KEY ("assignedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSlot" ADD CONSTRAINT "VolunteerSlot_checkedInById_fkey" FOREIGN KEY ("checkedInById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSlot" ADD CONSTRAINT "VolunteerSlot_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSwap" ADD CONSTRAINT "VolunteerSwap_slotId_fkey" FOREIGN KEY ("slotId") REFERENCES "VolunteerSlot"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSwap" ADD CONSTRAINT "VolunteerSwap_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "ServingRole"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSwap" ADD CONSTRAINT "VolunteerSwap_fromUserId_fkey" FOREIGN KEY ("fromUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSwap" ADD CONSTRAINT "VolunteerSwap_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -84,6 +84,10 @@ model User {
|
||||
// The privacy notice version they last accepted (0 = the built-in notice). Null = never
|
||||
// recorded. Asked again when a version that needs re-acceptance is newer.
|
||||
noticeVersionAccepted Int?
|
||||
// Retention: when they last logged in (kept here because login history itself expires),
|
||||
// and when they were warned that their inactive account is about to be deleted.
|
||||
lastLoginAt DateTime?
|
||||
inactivityWarnedAt DateTime?
|
||||
registrations Registration[]
|
||||
payments Payment[]
|
||||
paymentsRecorded Payment[] @relation("PaymentRecordedBy")
|
||||
@@ -136,6 +140,61 @@ model User {
|
||||
noticesPublished PrivacyNotice[]
|
||||
noticeAcceptances NoticeAcceptance[]
|
||||
dataAccessLogs DataAccessLog[]
|
||||
breachesRecorded SecurityBreach[]
|
||||
|
||||
// Multi-campus (only used while Site settings → Campuses is on).
|
||||
homeCampusId String?
|
||||
homeCampus Campus? @relation("UserHomeCampus", fields: [homeCampusId], references: [id], onDelete: SetNull)
|
||||
campusSummaries Campus[] @relation("CampusNotifyRecipients")
|
||||
// Campus-scoped staff (1.44.0): a supervisor/staff member limited to these campuses (their
|
||||
// events plus events for all campuses). Empty = unscoped. Ignored for admins and while
|
||||
// campuses are off (utils/campusScope.js).
|
||||
scopedCampuses Campus[] @relation("UserCampusScope")
|
||||
|
||||
// Kids check-in (1.45.0): the family this person is a parent in (at most one).
|
||||
familyMembership FamilyMember?
|
||||
kidsCheckInsDone KidsCheckIn[] @relation("KidsCheckInBy")
|
||||
kidsCheckOutsDone KidsCheckIn[] @relation("KidsCheckOutBy")
|
||||
kidsAlertsRaised KidsAlert[]
|
||||
kidsRoomDuties KidsRoomDuty[]
|
||||
|
||||
// Volunteer rosters (1.46.0)
|
||||
volunteerSlots VolunteerSlot[] @relation("VolunteerSlotUser")
|
||||
volunteersAssigned VolunteerSlot[] @relation("VolunteerSlotAssignedBy")
|
||||
volunteersCheckedIn VolunteerSlot[] @relation("VolunteerSlotCheckedInBy")
|
||||
swapsAsked VolunteerSwap[] @relation("VolunteerSwapFrom")
|
||||
swapsTaken VolunteerSwap[] @relation("VolunteerSwapTo")
|
||||
|
||||
@@index([homeCampusId])
|
||||
}
|
||||
|
||||
// A campus of the church (Site settings → Campuses, off by default). Events belong to one
|
||||
// campus, or to all campuses when Event.campusId is null. Archived instead of deleted once
|
||||
// it has events.
|
||||
model Campus {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
address String?
|
||||
contactName String?
|
||||
contactEmail String?
|
||||
contactPhone String?
|
||||
// Accent colour (#rrggbb) and logo shown with the campus on the public site.
|
||||
colour String?
|
||||
logoUrl String?
|
||||
// Pre-fills Event.location when an event is created for this campus.
|
||||
defaultLocation String?
|
||||
order Int @default(0)
|
||||
archivedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
events Event[]
|
||||
homeOf User[] @relation("UserHomeCampus")
|
||||
// Get the daily summary for this campus's events (on top of each event's own recipients).
|
||||
notifyRecipients User[] @relation("CampusNotifyRecipients")
|
||||
scopedStaff User[] @relation("UserCampusScope")
|
||||
|
||||
@@index([order])
|
||||
}
|
||||
|
||||
model Event {
|
||||
@@ -182,6 +241,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?
|
||||
@@ -238,9 +300,29 @@ model Event {
|
||||
sessions EventSession[]
|
||||
certificates Certificate[]
|
||||
|
||||
// Kids check-in (1.45.0): children are checked in at the kids desk instead of buying
|
||||
// tickets. Each child gets a free place on `kidsOptionId` (a main-ticket option of this
|
||||
// event) the first time, so they show on the attendance register; rooms are the groups of
|
||||
// the allocation set `kidsRoomSetId`. Plain ids (checked in utils/kids.js), not relations.
|
||||
kidsCheckIn Boolean @default(false)
|
||||
kidsOptionId String?
|
||||
kidsRoomSetId String?
|
||||
kidsPickupCodes KidsPickupCode[]
|
||||
kidsCheckIns KidsCheckIn[]
|
||||
kidsAlerts KidsAlert[]
|
||||
|
||||
// Volunteer rosters (1.46.0): serving roles for the event or one of its sessions. When
|
||||
// `volunteerOptionId` is set (a main ticket of this event; off by default), each volunteer
|
||||
// gets a free place on it. `volunteerCheckInToken` is in the check-in QR code volunteers scan
|
||||
// on the day (made the first time the QR code is shown). Plain id, checked in utils/volunteers.js.
|
||||
servingRoles ServingRole[]
|
||||
volunteerOptionId String?
|
||||
volunteerCheckInToken String? @unique
|
||||
|
||||
@@index([createdById])
|
||||
@@index([closedById])
|
||||
@@index([reopenedById])
|
||||
@@index([campusId])
|
||||
}
|
||||
|
||||
model EventOption {
|
||||
@@ -332,6 +414,7 @@ model Registration {
|
||||
bundleApplications RegistrationBundle[]
|
||||
attendees Attendee[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
volunteerSlots VolunteerSlot[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([eventId])
|
||||
@@ -593,6 +676,29 @@ enum AdminAuditAction {
|
||||
operator_created
|
||||
operator_updated
|
||||
operator_deleted
|
||||
retention_policies_updated
|
||||
retention_run
|
||||
breach_recorded
|
||||
breach_updated
|
||||
hub_connected
|
||||
hub_update_requested
|
||||
campuses_toggled
|
||||
campus_created
|
||||
campus_updated
|
||||
campus_archived
|
||||
campus_deleted
|
||||
event_campus_changed
|
||||
user_campus_scope_changed
|
||||
kids_settings_updated
|
||||
kids_checkout_override
|
||||
kids_wrong_code
|
||||
kids_custody_notes_changed
|
||||
kids_offline_sync
|
||||
volunteer_settings_updated
|
||||
volunteer_roles_changed
|
||||
volunteer_assigned
|
||||
volunteer_removed
|
||||
volunteer_checked_in_manual
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -1266,11 +1372,20 @@ model AllocationGroup {
|
||||
gender String?
|
||||
leaderUserId String?
|
||||
order Int @default(0)
|
||||
// Kids rooms (1.45.0): the ages the room is for (whole years, inclusive) and how many
|
||||
// children one leader may look after. All optional.
|
||||
minAge Int?
|
||||
maxAge Int?
|
||||
ratio Int?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
set AllocationSet @relation(fields: [setId], references: [id], onDelete: Cascade)
|
||||
leader User? @relation("AllocationGroupLeader", fields: [leaderUserId], references: [id], onDelete: SetNull)
|
||||
assignments AllocationAssignment[]
|
||||
kidsCheckIns KidsCheckIn[]
|
||||
kidsAlerts KidsAlert[]
|
||||
kidsDuties KidsRoomDuty[]
|
||||
servingRoles ServingRole[]
|
||||
|
||||
@@index([setId])
|
||||
@@index([leaderUserId])
|
||||
@@ -1546,6 +1661,7 @@ model EventSession {
|
||||
reminders SessionReminderLog[]
|
||||
registrationOptions RegistrationOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
servingRoles ServingRole[]
|
||||
|
||||
@@index([eventId, startsAt])
|
||||
}
|
||||
@@ -1724,6 +1840,8 @@ model DataAccessLog {
|
||||
what String
|
||||
eventId String?
|
||||
records Int?
|
||||
// Children's or special personal information (youth events, medical details).
|
||||
sensitive Boolean @default(false)
|
||||
ip String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -1731,3 +1849,285 @@ model DataAccessLog {
|
||||
@@index([actorId, createdAt])
|
||||
@@index([action, createdAt])
|
||||
}
|
||||
|
||||
enum BreachStatus {
|
||||
open
|
||||
contained
|
||||
closed
|
||||
}
|
||||
|
||||
// POPIA s22: a security compromise — what happened, whose information, what was done, and
|
||||
// when the Information Regulator and the people affected were told.
|
||||
model SecurityBreach {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
status BreachStatus @default(open)
|
||||
discoveredAt DateTime
|
||||
occurredAt DateTime?
|
||||
description String
|
||||
dataAffected String
|
||||
peopleAffected Int?
|
||||
// Free text: who (groups of people, events…), since a breach can involve people without accounts.
|
||||
whoAffected String?
|
||||
consequences String?
|
||||
actionsTaken String?
|
||||
adviceToPeople String?
|
||||
// Who caused it, if known (POPIA asks for this when notifying).
|
||||
causedBy String?
|
||||
regulatorNotifiedAt DateTime?
|
||||
peopleNotifiedAt DateTime?
|
||||
createdById String?
|
||||
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status, discoveredAt])
|
||||
}
|
||||
|
||||
// ─── Kids check-in (1.45.0) ───────────────────────────────────────────────────
|
||||
|
||||
// A household whose children are checked in at the kids desk. Kept between events (weekly
|
||||
// kids church). `qrToken` is what the parent's family QR in My account → Kids holds.
|
||||
model Family {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
qrToken String @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
members FamilyMember[]
|
||||
children Child[]
|
||||
adults AuthorisedAdult[]
|
||||
codes KidsPickupCode[]
|
||||
}
|
||||
|
||||
// A parent account in a family. A person is a parent in one family at most.
|
||||
model FamilyMember {
|
||||
id String @id @default(uuid())
|
||||
familyId String
|
||||
userId String @unique
|
||||
createdAt DateTime @default(now())
|
||||
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([familyId])
|
||||
}
|
||||
|
||||
// A child in a family. allergies, medicalNotes and custodyNotes are encrypted
|
||||
// (utils/encryption.js). Deleted by the retention job after months without a check-in.
|
||||
model Child {
|
||||
id String @id @default(uuid())
|
||||
familyId String
|
||||
firstName String
|
||||
lastName String?
|
||||
dateOfBirth DateTime?
|
||||
gender String?
|
||||
allergies String?
|
||||
medicalNotes String?
|
||||
custodyNotes String?
|
||||
lastCheckInAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||
checkIns KidsCheckIn[]
|
||||
|
||||
@@index([familyId])
|
||||
@@index([lastCheckInAt])
|
||||
}
|
||||
|
||||
// Someone the family allows to collect their children, who needn't have an account (a
|
||||
// grandparent, a friend). Anyone with the pickup code can collect; this list is what the
|
||||
// room leader checks the person against, and who the code can be sent to.
|
||||
model AuthorisedAdult {
|
||||
id String @id @default(uuid())
|
||||
familyId String
|
||||
name String
|
||||
phone String?
|
||||
relationship String?
|
||||
createdAt DateTime @default(now())
|
||||
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([familyId])
|
||||
}
|
||||
|
||||
// A pickup code: one per family per occasion (a session, or the day on a single-date event),
|
||||
// shared by the children checked in together. Codes for offline desks are reserved ahead with
|
||||
// `deviceId` and no family, and claimed when the desk syncs.
|
||||
model KidsPickupCode {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
occasion String
|
||||
code String
|
||||
familyId String?
|
||||
deviceId String?
|
||||
createdAt DateTime @default(now())
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
family Family? @relation(fields: [familyId], references: [id], onDelete: SetNull)
|
||||
checkIns KidsCheckIn[]
|
||||
|
||||
@@unique([eventId, occasion, code])
|
||||
@@unique([eventId, occasion, familyId])
|
||||
@@index([eventId, occasion, deviceId])
|
||||
}
|
||||
|
||||
// One child checked in to a room for an occasion. childId is cleared when the child's profile
|
||||
// is deleted; the attendance (attendee, ticket, ticket usage) stays.
|
||||
model KidsCheckIn {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
occasion String
|
||||
sessionId String?
|
||||
childId String?
|
||||
childName String
|
||||
roomId String?
|
||||
pickupCodeId String
|
||||
attendeeId String?
|
||||
ticketId String?
|
||||
checkedInAt DateTime @default(now())
|
||||
checkedInById String?
|
||||
checkedOutAt DateTime?
|
||||
checkedOutById String?
|
||||
collectedBy String?
|
||||
overrideReason String?
|
||||
// Offline desks: the device's id for the check-in (makes syncing idempotent).
|
||||
clientId String? @unique
|
||||
offline Boolean @default(false)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
child Child? @relation(fields: [childId], references: [id], onDelete: SetNull)
|
||||
room AllocationGroup? @relation(fields: [roomId], references: [id], onDelete: SetNull)
|
||||
pickupCode KidsPickupCode @relation(fields: [pickupCodeId], references: [id], onDelete: Cascade)
|
||||
checkedInBy User? @relation("KidsCheckInBy", fields: [checkedInById], references: [id], onDelete: SetNull)
|
||||
checkedOutBy User? @relation("KidsCheckOutBy", fields: [checkedOutById], references: [id], onDelete: SetNull)
|
||||
alerts KidsAlert[]
|
||||
|
||||
@@unique([eventId, occasion, childId])
|
||||
@@index([eventId, occasion])
|
||||
@@index([roomId])
|
||||
@@index([pickupCodeId])
|
||||
}
|
||||
|
||||
enum KidsAlertKind {
|
||||
wrong_code
|
||||
page_parent
|
||||
override
|
||||
}
|
||||
|
||||
// Something the room needs to see: a wrong pickup code, a parent paged, an override.
|
||||
model KidsAlert {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
occasion String
|
||||
kind KidsAlertKind
|
||||
roomId String?
|
||||
checkInId String?
|
||||
message String
|
||||
createdById String?
|
||||
createdAt DateTime @default(now())
|
||||
resolvedAt DateTime?
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
room AllocationGroup? @relation(fields: [roomId], references: [id], onDelete: SetNull)
|
||||
checkIn KidsCheckIn? @relation(fields: [checkInId], references: [id], onDelete: SetNull)
|
||||
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([eventId, occasion, createdAt])
|
||||
@@index([checkInId])
|
||||
}
|
||||
|
||||
// A leader on duty in a kids room for an occasion ("I'm on duty"), for the ratio.
|
||||
model KidsRoomDuty {
|
||||
id String @id @default(uuid())
|
||||
roomId String
|
||||
occasion String
|
||||
userId String
|
||||
startedAt DateTime @default(now())
|
||||
endedAt DateTime?
|
||||
// Volunteer rosters (1.46.0): started by a rostered volunteer checking in, not "I'm on duty".
|
||||
fromRoster Boolean @default(false)
|
||||
room AllocationGroup @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([roomId, occasion])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// ─── Volunteer rosters (1.46.0) ───────────────────────────────────────────────
|
||||
|
||||
// A serving role (Welcome, Parking, Sound…) for one event — or, on a multi-session event, one
|
||||
// session — with the number of people needed. Shift times are optional (null = the session's or
|
||||
// event's times). A role linked to a kids room puts its volunteers on duty there when they check
|
||||
// in, so they count for the room's ratio.
|
||||
model ServingRole {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
sessionId String?
|
||||
name String
|
||||
description String?
|
||||
needed Int @default(1)
|
||||
startsAt DateTime?
|
||||
endsAt DateTime?
|
||||
kidsRoomId String?
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
kidsRoom AllocationGroup? @relation(fields: [kidsRoomId], references: [id], onDelete: SetNull)
|
||||
slots VolunteerSlot[]
|
||||
swaps VolunteerSwap[]
|
||||
|
||||
@@index([eventId, sessionId])
|
||||
@@index([kidsRoomId])
|
||||
}
|
||||
|
||||
// A person serving in a role. `assignedById` is the supervisor who put them there (null = they
|
||||
// signed up). Withdrawing or being removed deletes the row; a swap moves it to the person who
|
||||
// takes it over. `registrationId` is the free volunteer place they were given, if any.
|
||||
model VolunteerSlot {
|
||||
id String @id @default(uuid())
|
||||
roleId String
|
||||
userId String
|
||||
assignedById String?
|
||||
checkedInAt DateTime?
|
||||
checkedInById String?
|
||||
// 'qr' (the volunteer scanned the check-in QR code) or 'manual' (ticked by staff)
|
||||
checkInMethod String?
|
||||
remindedAt DateTime?
|
||||
registrationId String?
|
||||
createdAt DateTime @default(now())
|
||||
role ServingRole @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
user User @relation("VolunteerSlotUser", fields: [userId], references: [id], onDelete: Cascade)
|
||||
assignedBy User? @relation("VolunteerSlotAssignedBy", fields: [assignedById], references: [id], onDelete: SetNull)
|
||||
checkedInBy User? @relation("VolunteerSlotCheckedInBy", fields: [checkedInById], references: [id], onDelete: SetNull)
|
||||
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
|
||||
swaps VolunteerSwap[]
|
||||
|
||||
@@unique([roleId, userId])
|
||||
@@index([userId])
|
||||
@@index([registrationId])
|
||||
}
|
||||
|
||||
enum VolunteerSwapStatus {
|
||||
open
|
||||
accepted
|
||||
cancelled
|
||||
}
|
||||
|
||||
// "Can't make it": the volunteer asks someone to take their place. It happens as soon as someone
|
||||
// accepts (the slot moves to them), and the event's notify recipients are told.
|
||||
model VolunteerSwap {
|
||||
id String @id @default(uuid())
|
||||
slotId String?
|
||||
roleId String
|
||||
fromUserId String?
|
||||
toUserId String?
|
||||
note String?
|
||||
status VolunteerSwapStatus @default(open)
|
||||
createdAt DateTime @default(now())
|
||||
resolvedAt DateTime?
|
||||
slot VolunteerSlot? @relation(fields: [slotId], references: [id], onDelete: SetNull)
|
||||
role ServingRole @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
fromUser User? @relation("VolunteerSwapFrom", fields: [fromUserId], references: [id], onDelete: SetNull)
|
||||
toUser User? @relation("VolunteerSwapTo", fields: [toUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([roleId, status])
|
||||
@@index([slotId])
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@ const cleanCapacity = (v) => {
|
||||
if (!Number.isFinite(n) || n < 1 || n > 10000) throw allocationError('Capacity must be a whole number of at least 1.');
|
||||
return n;
|
||||
};
|
||||
// Kids rooms: ages in whole years and children per leader. Blank = no limit.
|
||||
const cleanSmallInt = (v, what, min, max) => {
|
||||
if (v === undefined || v === null || v === '') return null;
|
||||
const n = parseInt(v, 10);
|
||||
if (!Number.isFinite(n) || n < min || n > max) throw allocationError(`${what} must be a whole number from ${min} to ${max}.`);
|
||||
return n;
|
||||
};
|
||||
|
||||
/** Named attendees on the event's live registrations. */
|
||||
async function eventAttendees(eventId) {
|
||||
@@ -110,8 +117,14 @@ const deleteSet = async (req, res) => {
|
||||
};
|
||||
|
||||
async function groupData(body, setId, existing = null) {
|
||||
const { name, capacity, gender, leaderUserId } = body || {};
|
||||
const { name, capacity, gender, leaderUserId, minAge, maxAge, ratio } = body || {};
|
||||
const data = {};
|
||||
if (minAge !== undefined) data.minAge = cleanSmallInt(minAge, 'The youngest age', 0, 25);
|
||||
if (maxAge !== undefined) data.maxAge = cleanSmallInt(maxAge, 'The oldest age', 0, 25);
|
||||
if (ratio !== undefined) data.ratio = cleanSmallInt(ratio, 'Children per leader', 1, 100);
|
||||
const lo = data.minAge !== undefined ? data.minAge : existing?.minAge;
|
||||
const hi = data.maxAge !== undefined ? data.maxAge : existing?.maxAge;
|
||||
if (lo != null && hi != null && lo > hi) throw allocationError("The youngest age can't be more than the oldest age.");
|
||||
if (name !== undefined || !existing) data.name = cleanName(name, 'group');
|
||||
if (capacity !== undefined) data.capacity = cleanCapacity(capacity);
|
||||
if (gender !== undefined) data.gender = cleanGender(gender);
|
||||
@@ -132,7 +145,7 @@ async function groupData(body, setId, existing = null) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// @route POST /api/allocations/sets/:id/groups { name, capacity?, gender?, leaderUserId? }
|
||||
// @route POST /api/allocations/sets/:id/groups { name, capacity?, gender?, leaderUserId?, minAge?, maxAge?, ratio? }
|
||||
const createGroup = async (req, res) => {
|
||||
try {
|
||||
const set = await loadSet(req.params.id);
|
||||
|
||||
@@ -21,9 +21,13 @@ const trackStage = async (req, res) => {
|
||||
// @access Private/Supervisor
|
||||
const getFunnel = async (req, res) => {
|
||||
try {
|
||||
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
|
||||
if (eventIds.length === 0) { res.status(400); throw new Error('Choose at least one event'); }
|
||||
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { id: true, title: true } });
|
||||
const asked = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
|
||||
if (asked.length === 0) { res.status(400); throw new Error('Choose at least one event'); }
|
||||
// Campus-scoped supervisors only get the events they can see.
|
||||
const scopeWhere = await require('../utils/campusScope').listWhere(req);
|
||||
const events = await prisma.event.findMany({ where: { id: { in: asked }, ...scopeWhere }, select: { id: true, title: true } });
|
||||
const eventIds = events.map(e => e.id);
|
||||
if (eventIds.length === 0) { res.status(404); throw new Error('Event not found'); }
|
||||
const total = await funnelFor(eventIds, { from: req.query.from, to: req.query.to });
|
||||
// Per event too, so events can be compared.
|
||||
const perEvent = [];
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
|
||||
const { SETTING_KEY, CAMPUS_PUBLIC_SELECT, campusesEnabled, cleanCampusInput, campusAudienceWhere } = require('../utils/campuses');
|
||||
|
||||
const audit = (req, action, campus, metadata) => logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'Campus', targetId: campus?.id, metadata: { name: campus?.name, ...metadata }, ip: getClientIp(req),
|
||||
});
|
||||
|
||||
// A slug that's already taken → a message instead of a Prisma error.
|
||||
function friendlyError(e) {
|
||||
if (e?.code === 'P2002') return 'Another campus already uses that web address — choose a different one.';
|
||||
return e.message || safeErrorMessage(e);
|
||||
}
|
||||
|
||||
const ADMIN_INCLUDE = {
|
||||
notifyRecipients: { select: { id: true, name: true, email: true } },
|
||||
_count: { select: { events: true, homeOf: true, scopedStaff: true } },
|
||||
};
|
||||
|
||||
// @desc Whether campuses are on, and the active campuses (public site filters, event cards)
|
||||
// @route GET /api/campuses
|
||||
// @access Public
|
||||
const getCampuses = async (req, res) => {
|
||||
try {
|
||||
const enabled = await campusesEnabled();
|
||||
const campuses = enabled
|
||||
? await prisma.campus.findMany({ where: { archivedAt: null }, select: CAMPUS_PUBLIC_SELECT, orderBy: [{ order: 'asc' }, { name: 'asc' }] })
|
||||
: [];
|
||||
res.json({ enabled, campuses });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc A campus landing page: the campus (active only)
|
||||
// @route GET /api/campuses/by-slug/:slug
|
||||
// @access Public
|
||||
const getCampusBySlug = async (req, res) => {
|
||||
try {
|
||||
const campus = (await campusesEnabled())
|
||||
? await prisma.campus.findFirst({ where: { slug: String(req.params.slug), archivedAt: null }, select: CAMPUS_PUBLIC_SELECT })
|
||||
: null;
|
||||
if (!campus) return res.status(404).json({ message: 'Campus not found' });
|
||||
res.json(campus);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc All campuses with archived ones, event counts and summary recipients (Site settings → Campuses)
|
||||
// @route GET /api/campuses/admin
|
||||
// @access Private/Admin
|
||||
const getCampusesAdmin = async (req, res) => {
|
||||
try {
|
||||
const [enabled, campuses] = await Promise.all([
|
||||
campusesEnabled(),
|
||||
prisma.campus.findMany({ include: ADMIN_INCLUDE, orderBy: [{ archivedAt: { sort: 'asc', nulls: 'first' } }, { order: 'asc' }, { name: 'asc' }] }),
|
||||
]);
|
||||
res.json({ enabled, campuses });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Turn campuses on or off (off hides campus fields and filters; nothing is deleted)
|
||||
// @route PUT /api/campuses/settings body: { enabled }
|
||||
// @access Private/Admin
|
||||
const updateCampusSettings = async (req, res) => {
|
||||
try {
|
||||
const enabled = req.body?.enabled === true;
|
||||
await prisma.appSetting.upsert({ where: { key: SETTING_KEY }, update: { value: String(enabled) }, create: { key: SETTING_KEY, value: String(enabled) } });
|
||||
invalidateSettingsCache();
|
||||
await warmCache();
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'campuses_toggled', targetType: 'AppSetting', metadata: { enabled }, ip: getClientIp(req) });
|
||||
res.json({ enabled });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Add a campus (added at the end of the list)
|
||||
// @route POST /api/campuses
|
||||
// @access Private/Admin
|
||||
const createCampus = async (req, res) => {
|
||||
try {
|
||||
const { data, error } = cleanCampusInput(req.body);
|
||||
if (error) return res.status(400).json({ message: error });
|
||||
const last = await prisma.campus.aggregate({ _max: { order: true } });
|
||||
const campus = await prisma.campus.create({ data: { ...data, order: (last._max.order ?? -1) + 1 }, include: ADMIN_INCLUDE });
|
||||
audit(req, 'campus_created', campus, { slug: campus.slug });
|
||||
res.status(201).json(campus);
|
||||
} catch (e) {
|
||||
res.status(e?.code === 'P2002' ? 409 : 400).json({ message: friendlyError(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Change a campus: details, summary recipients, archived or not
|
||||
// @route PUT /api/campuses/:id body: campus fields, notifyRecipientIds?, archived?
|
||||
// @access Private/Admin
|
||||
const updateCampus = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.campus.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) return res.status(404).json({ message: 'Campus not found' });
|
||||
const { data, error } = cleanCampusInput(req.body, { partial: true });
|
||||
if (error) return res.status(400).json({ message: error });
|
||||
|
||||
if (Array.isArray(req.body?.notifyRecipientIds)) {
|
||||
const ids = req.body.notifyRecipientIds.filter(x => typeof x === 'string' && x);
|
||||
// Summaries go to people who run events — never to ordinary users.
|
||||
const staff = await prisma.user.findMany({ where: { id: { in: ids }, role: { in: ['admin', 'supervisor', 'staff'] } }, select: { id: true } });
|
||||
data.notifyRecipients = { set: staff.map(u => ({ id: u.id })) };
|
||||
}
|
||||
let archiveChange = null;
|
||||
if (req.body?.archived !== undefined) {
|
||||
const archived = req.body.archived === true;
|
||||
if (archived !== !!existing.archivedAt) {
|
||||
data.archivedAt = archived ? new Date() : null;
|
||||
archiveChange = archived;
|
||||
}
|
||||
}
|
||||
|
||||
const campus = await prisma.campus.update({ where: { id: existing.id }, data, include: ADMIN_INCLUDE });
|
||||
if (archiveChange !== null) audit(req, 'campus_archived', campus, { archived: archiveChange });
|
||||
const changed = Object.keys(data).filter(k => k !== 'archivedAt');
|
||||
if (changed.length) audit(req, 'campus_updated', campus, { changed });
|
||||
res.json(campus);
|
||||
} catch (e) {
|
||||
res.status(e?.code === 'P2002' ? 409 : 400).json({ message: friendlyError(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Put the campuses in this order (filters and lists follow it)
|
||||
// @route PUT /api/campuses/order body: { ids: [...] }
|
||||
// @access Private/Admin
|
||||
const reorderCampuses = async (req, res) => {
|
||||
try {
|
||||
const ids = Array.isArray(req.body?.ids) ? req.body.ids.filter(x => typeof x === 'string') : [];
|
||||
if (!ids.length) return res.status(400).json({ message: 'Send the campus ids in their new order.' });
|
||||
await prisma.$transaction(ids.map((id, order) => prisma.campus.updateMany({ where: { id }, data: { order } })));
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Delete a campus that has no events (otherwise archive it). Home campus is cleared.
|
||||
// @route DELETE /api/campuses/:id
|
||||
// @access Private/Admin
|
||||
const deleteCampus = async (req, res) => {
|
||||
try {
|
||||
const campus = await prisma.campus.findUnique({ where: { id: req.params.id }, include: { _count: { select: { events: true, scopedStaff: true } } } });
|
||||
if (!campus) return res.status(404).json({ message: 'Campus not found' });
|
||||
// Deleting would drop their limit and leave them unscoped (seeing every campus).
|
||||
if (campus._count.scopedStaff > 0) {
|
||||
return res.status(409).json({ code: 'CAMPUS_HAS_SCOPED_STAFF', message: `${campus._count.scopedStaff} staff member${campus._count.scopedStaff === 1 ? ' is' : 's are'} limited to ${campus.name}. Change their campuses in Admin → Users first, or archive the campus.` });
|
||||
}
|
||||
if (campus._count.events > 0) {
|
||||
return res.status(409).json({ code: 'CAMPUS_HAS_EVENTS', message: `${campus.name} has ${campus._count.events} event${campus._count.events === 1 ? '' : 's'}, so it can't be deleted. Archive it instead.` });
|
||||
}
|
||||
await prisma.campus.delete({ where: { id: campus.id } });
|
||||
audit(req, 'campus_deleted', campus, { slug: campus.slug });
|
||||
res.json({ message: 'Campus deleted' });
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc "Everyone at campus X" for the email/WhatsApp composers: home campus X, plus (with
|
||||
// ?registrants=true) people registered for X's events in the last 12 months. The
|
||||
// composer sends to these ids as usual, so news consent is still checked on sending.
|
||||
// @route GET /api/campuses/:id/audience?registrants=true
|
||||
// @access Private/Supervisor
|
||||
const getCampusAudience = async (req, res) => {
|
||||
try {
|
||||
const campus = await prisma.campus.findUnique({ where: { id: req.params.id }, select: { id: true, name: true } });
|
||||
if (!campus) return res.status(404).json({ message: 'Campus not found' });
|
||||
const scope = await require('../utils/campusScope').scopeFor(req);
|
||||
if (scope && !scope.campusIds.includes(campus.id)) return res.status(404).json({ message: 'Campus not found' });
|
||||
const users = await prisma.user.findMany({
|
||||
where: campusAudienceWhere(campus.id, { includeRegistrants: req.query.registrants === 'true' }),
|
||||
select: { id: true, name: true, email: true, phoneNumber: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
res.json({ campus, users });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getCampusAudience, getCampuses, getCampusBySlug, getCampusesAdmin, updateCampusSettings, createCampus, updateCampus, reorderCampuses, deleteCampus };
|
||||
@@ -291,9 +291,12 @@ const resolvePending = async (req, res) => {
|
||||
const getCashupAudit = async (req, res) => {
|
||||
try {
|
||||
const { eventId, from, to } = req.query;
|
||||
// Campus-scoped supervisors only see cashups they could do: their campuses' events.
|
||||
const scope = await require('../utils/campusScope').scopeFor(req);
|
||||
|
||||
const where = {};
|
||||
if (eventId) where.eventId = eventId;
|
||||
if (scope) where.event = require('../utils/campusScope').manageWhere(scope);
|
||||
if (from || to) {
|
||||
where.createdAt = {};
|
||||
if (from) where.createdAt.gte = new Date(from);
|
||||
|
||||
@@ -5,18 +5,29 @@ const { getClientIp } = require('../utils/requestUtils');
|
||||
const { normalizeCode, previewCoupon, buildCouponLines, describeCoupon, countCouponUses } = require('../utils/coupons');
|
||||
const { resolveOptionPrice, resolveVariantTierPrice } = require('../utils/pricing');
|
||||
const { describeBundle } = require('../utils/bundles');
|
||||
const { scopeFor, couponAllowed, listWhere } = require('../utils/campusScope');
|
||||
|
||||
const TYPES = ['percent', 'fixed', 'comp'];
|
||||
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
|
||||
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
|
||||
|
||||
const COUPON_DETAIL_INCLUDE = {
|
||||
events: { include: { event: { select: { id: true, title: true, startDate: true } } } },
|
||||
events: { include: { event: { select: { id: true, title: true, startDate: true, campusId: true } } } },
|
||||
options: { include: { eventOption: { select: { id: true, name: true, eventId: true } }, variant: { select: { id: true, name: true } } } },
|
||||
allowedUsers: { include: { user: { select: { id: true, name: true, email: true } } } },
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
};
|
||||
|
||||
// Campus-scoped supervisors: only coupons limited to events at their campuses (couponAllowed).
|
||||
const couponCampuses = coupon => (coupon.events || []).map(ce => ce.event?.campusId ?? null);
|
||||
async function eventIdsAllowed(scope, eventIds) {
|
||||
if (!scope) return true;
|
||||
if (!eventIds || eventIds.length === 0) return false; // a coupon for every event
|
||||
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { campusId: true } });
|
||||
return events.length === eventIds.length && couponAllowed(scope, events.map(e => e.campusId));
|
||||
}
|
||||
const SCOPED_COUPON_MSG = 'Supervisors for some campuses can only make coupons limited to events at their campuses.';
|
||||
|
||||
const optionalDate = (v, label) => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
@@ -119,7 +130,9 @@ function relationWrites({ eventIds, scopes, userIds }, isCreate) {
|
||||
// @access Private/Supervisor
|
||||
const listCoupons = async (req, res) => {
|
||||
try {
|
||||
const coupons = await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } });
|
||||
const scope = await scopeFor(req);
|
||||
const coupons = (await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } }))
|
||||
.filter(c => couponAllowed(scope, couponCampuses(c)));
|
||||
const totals = await prisma.couponRedemption.groupBy({
|
||||
by: ['couponId'],
|
||||
where: { releasedAt: null, registration: { status: { not: 'cancelled' } } },
|
||||
@@ -157,7 +170,7 @@ const getCoupon = async (req, res) => {
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!coupon) { res.status(404); throw new Error('Coupon not found'); }
|
||||
if (!coupon || !couponAllowed(await scopeFor(req), couponCampuses(coupon))) { res.status(404); throw new Error('Coupon not found'); }
|
||||
res.json({ ...coupon, summary: describeCoupon(coupon) });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
@@ -173,6 +186,7 @@ const createCoupon = async (req, res) => {
|
||||
try {
|
||||
parsed = await parseCouponBody(req.body || {}, { isCreate: true });
|
||||
} catch (e) { res.status(400); throw e; }
|
||||
if (!(await eventIdsAllowed(await scopeFor(req), parsed.eventIds))) { res.status(403); throw new Error(SCOPED_COUPON_MSG); }
|
||||
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
|
||||
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
|
||||
}
|
||||
@@ -198,12 +212,14 @@ const createCoupon = async (req, res) => {
|
||||
// @access Private/Supervisor
|
||||
const updateCoupon = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.coupon.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) { res.status(404); throw new Error('Coupon not found'); }
|
||||
const existing = await prisma.coupon.findUnique({ where: { id: req.params.id }, include: { events: { include: { event: { select: { campusId: true } } } } } });
|
||||
const scope = await scopeFor(req);
|
||||
if (!existing || !couponAllowed(scope, couponCampuses(existing))) { res.status(404); throw new Error('Coupon not found'); }
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseCouponBody({ type: existing.type, ...req.body }, { isCreate: false });
|
||||
} catch (e) { res.status(400); throw e; }
|
||||
if (parsed.eventIds !== undefined && !(await eventIdsAllowed(scope, parsed.eventIds))) { res.status(403); throw new Error(SCOPED_COUPON_MSG); }
|
||||
if (parsed.data.code && parsed.data.code !== existing.code) {
|
||||
const used = await prisma.couponRedemption.count({ where: { couponId: existing.id } });
|
||||
if (used > 0) { res.status(400); throw new Error('This code has been used, so it can\'t be renamed. Turn it off and create a new one instead.'); }
|
||||
@@ -327,9 +343,10 @@ const listRedemptions = async (req, res) => {
|
||||
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const from = req.query.from ? new Date(req.query.from) : null;
|
||||
const to = req.query.to ? new Date(req.query.to) : null;
|
||||
const scopeEvent = await listWhere(req, 'event'); // { event: … } or {}
|
||||
const where = {
|
||||
releasedAt: null,
|
||||
registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}) },
|
||||
registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...scopeEvent },
|
||||
...(from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {}),
|
||||
};
|
||||
const rows = await prisma.couponRedemption.findMany({
|
||||
@@ -346,7 +363,7 @@ const listRedemptions = async (req, res) => {
|
||||
// discounts are recalculated, so dates go by when the registration was made.
|
||||
const regDate = from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {};
|
||||
const bundleRows = await prisma.registrationBundle.findMany({
|
||||
where: { registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...regDate } },
|
||||
where: { registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...regDate, ...scopeEvent } },
|
||||
include: {
|
||||
bundle: { include: { items: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } } } },
|
||||
registration: { select: { id: true, status: true, createdAt: true, user: { select: { id: true, name: true, email: true } }, event: { select: { id: true, title: true } } } },
|
||||
|
||||
@@ -8,6 +8,21 @@ const { assertEventOpen } = require('../utils/cashupUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const sessionsUtil = require('../utils/sessions');
|
||||
const { publicOptions } = require('../utils/volunteers');
|
||||
const { campusesEnabled, campusForEvent, campusWhere, resolveCampusFilter, EVENT_CAMPUS_SELECT } = require('../utils/campuses');
|
||||
const { scopeFor, canView, canManage, eventWhere: scopeEventWhere, withScope } = require('../utils/campusScope');
|
||||
|
||||
/** A campus-scoped supervisor may only create or move events to one of their campuses. */
|
||||
async function assertCampusAllowed(req, campusId) {
|
||||
const scope = await scopeFor(req);
|
||||
if (scope && !canManage(scope, campusId)) {
|
||||
const err = new Error(campusId == null
|
||||
? 'Choose one of your campuses — events for all campuses are set up by admins and supervisors for every campus.'
|
||||
: 'You can only put events at your own campuses.');
|
||||
err.statusCode = 403;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' }, include: { _count: { select: { usages: true } } } };
|
||||
|
||||
@@ -125,6 +140,11 @@ const createEvent = async (req, res) => {
|
||||
try {
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
|
||||
const campusPick = await campusForEvent(req.body.campusId, { enabled: await campusesEnabled() });
|
||||
if (campusPick.error) { const err = new Error(campusPick.error); err.statusCode = 400; throw err; }
|
||||
// Not choosing a campus means all campuses, which a scoped supervisor can't create.
|
||||
await assertCampusAllowed(req, campusPick.campusId === undefined ? null : campusPick.campusId);
|
||||
|
||||
const data = {
|
||||
id: uuidv4(),
|
||||
title,
|
||||
@@ -160,6 +180,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 +346,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)
|
||||
@@ -350,7 +375,8 @@ const getEvents = async (req, res) => {
|
||||
|
||||
// Compute isSoldOut per event: true when every option that has a stock limit
|
||||
// is fully sold out. Events with no limited options are never sold out.
|
||||
const withStock = await Promise.all(events.map(async ev => {
|
||||
const withStock = await Promise.all(events.map(async listed => {
|
||||
const ev = { ...listed, eventOptions: publicOptions(listed) };
|
||||
const earlyBirdEndsAt = earlyBirdEnds.get(ev.id) || null;
|
||||
try {
|
||||
// Picked-session options have per-session limits, so they never make the event sold out.
|
||||
@@ -383,6 +409,7 @@ const getAllEvents = async (req, res) => {
|
||||
include: {
|
||||
eventOptions: canIncludeTiers ? { include: { earlyBirdTiers: true } } : true,
|
||||
sessions: SESSIONS_INCLUDE,
|
||||
campus: { select: EVENT_CAMPUS_SELECT },
|
||||
}
|
||||
});
|
||||
|
||||
@@ -413,6 +440,10 @@ const getEventsAll = async (req, res) => {
|
||||
if (!includeInactive) where.isActive = true;
|
||||
if (!includePast) where.endDate = { gte: new Date() };
|
||||
if (excludeClosed) where.cashupStatus = { not: 'closed' };
|
||||
// ?campus=<id, slug or 'all'>: one campus's events, or only church-wide ones.
|
||||
const enabled = await campusesEnabled();
|
||||
Object.assign(where, campusWhere(enabled ? await resolveCampusFilter(req.query.campus) : '', { enabled }));
|
||||
const scoped = withScope(where, scopeEventWhere(await scopeFor(req)));
|
||||
|
||||
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
||||
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
|
||||
@@ -420,11 +451,12 @@ const getEventsAll = async (req, res) => {
|
||||
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) }
|
||||
: undefined;
|
||||
const events = await prisma.event.findMany({
|
||||
where,
|
||||
where: scoped,
|
||||
include: {
|
||||
eventOptions: optionInclude ? { include: optionInclude } : true,
|
||||
form: { include: { fields: true } },
|
||||
sessions: SESSIONS_INCLUDE,
|
||||
campus: { select: EVENT_CAMPUS_SELECT },
|
||||
},
|
||||
orderBy: { startDate: 'asc' }
|
||||
});
|
||||
@@ -452,7 +484,7 @@ const getEventById = async (req, res) => {
|
||||
const optionInclude = canIncludeTiers
|
||||
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) }
|
||||
: undefined;
|
||||
const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: SESSIONS_INCLUDE };
|
||||
const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: SESSIONS_INCLUDE, campus: { select: EVENT_CAMPUS_SELECT } };
|
||||
|
||||
// Staff/supervisor/admin need the creator + notify-recipient list to populate the
|
||||
// event edit screen's Notifications step; public callers don't need this.
|
||||
@@ -496,9 +528,14 @@ const getEventById = async (req, res) => {
|
||||
}
|
||||
|
||||
// Public gating: hide inactive or not-yet-live events from everyone except staff/supervisor/admin,
|
||||
// who need to load inactive events for cashup, editing, etc.
|
||||
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role));
|
||||
// who need to load inactive events for cashup, editing, etc. Campus-scoped staff get the
|
||||
// public view of other campuses' events (and not their notification recipients).
|
||||
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role)) && canView(await scopeFor(req), event.campusId);
|
||||
if (!isStaffOrHigher) {
|
||||
delete event.createdBy;
|
||||
delete event.notifyRecipients;
|
||||
// The kids check-in and volunteer tickets are handed out by staff, never sold.
|
||||
event.eventOptions = publicOptions(event);
|
||||
if (event.isActive === false) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
@@ -627,7 +664,7 @@ const getEventIcs = async (req, res) => {
|
||||
|
||||
// Same public-visibility gate as getEventById — a hidden/not-yet-live event's
|
||||
// details shouldn't be enumerable via this route either.
|
||||
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role));
|
||||
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role)) && canView(await scopeFor(req), event.campusId);
|
||||
if (!isStaffOrHigher) {
|
||||
if (event.isActive === false) {
|
||||
res.status(404);
|
||||
@@ -688,6 +725,13 @@ const updateEvent = async (req, res) => {
|
||||
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
|
||||
const campusPick = await campusForEvent(req.body.campusId, { enabled: await campusesEnabled(), currentCampusId: event.campusId });
|
||||
if (campusPick.error) throw new Error(campusPick.error);
|
||||
const campusChanged = campusPick.campusId !== undefined && campusPick.campusId !== event.campusId;
|
||||
if (campusChanged) {
|
||||
try { await assertCampusAllowed(req, campusPick.campusId); } catch (e) { return res.status(403).json({ message: e.message }); }
|
||||
}
|
||||
|
||||
const data = {
|
||||
title: title || event.title,
|
||||
description: description !== undefined ? description : event.description,
|
||||
@@ -725,6 +769,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 +794,12 @@ const updateEvent = async (req, res) => {
|
||||
: null;
|
||||
});
|
||||
sessionsUtil.forgetSchedule(updatedEvent.id);
|
||||
if (campusChanged) {
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'event_campus_changed',
|
||||
targetType: 'Event', targetId: updatedEvent.id, metadata: { from: event.campusId, to: campusPick.campusId }, ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
if (sessionChanges && Object.values(sessionChanges).some(n => n > 0)) {
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'event_sessions_changed',
|
||||
|
||||
@@ -151,7 +151,7 @@ const getSummary = async (req, res) => {
|
||||
try {
|
||||
const ids = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
|
||||
const forms = await prisma.feedbackForm.findMany({
|
||||
where: { eventId: { in: ids } },
|
||||
where: { eventId: { in: ids }, ...(await require('../utils/campusScope').listWhere(req, 'event')) },
|
||||
include: {
|
||||
event: { select: { id: true, title: true, startDate: true } },
|
||||
fields: { orderBy: { order: 'asc' } },
|
||||
|
||||
@@ -16,6 +16,7 @@ const listFormResponses = async (req, res) => {
|
||||
const registrationFilter = {};
|
||||
if (eventId) registrationFilter.eventId = String(eventId);
|
||||
if (userId) registrationFilter.userId = String(userId);
|
||||
Object.assign(registrationFilter, await require('../utils/campusScope').listWhere(req, 'event'));
|
||||
if (Object.keys(registrationFilter).length > 0) {
|
||||
// Prisma relation filter requires `is` wrapper
|
||||
where.registration = { is: registrationFilter };
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { licenceStatus } = require('../hub/licence');
|
||||
const hub = require('../hub/client');
|
||||
const { APP_VERSION } = require('../hub/report');
|
||||
|
||||
function compareVersions(a, b) {
|
||||
const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0);
|
||||
const pb = String(b || '').split('.').map(n => parseInt(n, 10) || 0);
|
||||
for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
|
||||
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 });
|
||||
return {
|
||||
state: s.state,
|
||||
readOnly: s.readOnly,
|
||||
daysLeft: s.daysLeft,
|
||||
endsAt: s.endsAt,
|
||||
graceEndsAt: s.graceEndsAt,
|
||||
licence: s.claims ? {
|
||||
customer: s.claims.customer,
|
||||
plan: s.claims.plan,
|
||||
status: s.claims.status,
|
||||
licenceExpiresAt: s.claims.licenceExpiresAt,
|
||||
graceDays: s.claims.graceDays,
|
||||
issuedAt: s.claims.issuedAt,
|
||||
validUntil: s.claims.validUntil,
|
||||
limits: s.claims.limits || {},
|
||||
} : null,
|
||||
connected: s.connected,
|
||||
keyConfigured: s.keyConfigured,
|
||||
hubUrl: hub.hubUrl(),
|
||||
lastHeartbeatAt: s.lastHeartbeatAt,
|
||||
lastError: s.lastError,
|
||||
version: APP_VERSION,
|
||||
latestVersion: s.latestVersion,
|
||||
updateAvailable: !!(s.latestVersion && compareVersions(APP_VERSION, s.latestVersion) < 0),
|
||||
updateRequest: pendingUpdateRequest(APP_VERSION, s.updateRequestedAt, s.updateRequestedVersion),
|
||||
};
|
||||
}
|
||||
|
||||
// @desc Licence and Hub status (for the admin banner and Site settings → Licence)
|
||||
// @route GET /api/hub/status
|
||||
// @access Private/Admin
|
||||
const getStatus = async (req, res) => {
|
||||
try {
|
||||
res.json(await statusPayload());
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Connect this install to the Hub with a licence key (also the /setup licence step)
|
||||
// @route POST /api/hub/connect body: { licenceKey }
|
||||
// @access Private/Admin
|
||||
const connect = async (req, res) => {
|
||||
try {
|
||||
const licenceKey = String(req.body?.licenceKey || '').trim();
|
||||
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) {
|
||||
const status = e.statusCode || (res.statusCode === 200 ? 400 : res.statusCode);
|
||||
res.status(status >= 500 && e.code ? 502 : status).json({ message: e.message || safeErrorMessage(e), code: e.code });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Send a heartbeat now (checks the connection and picks up licence changes)
|
||||
// @route POST /api/hub/heartbeat
|
||||
// @access Private/Admin
|
||||
const heartbeatNow = async (req, res) => {
|
||||
try {
|
||||
await hub.heartbeat();
|
||||
res.json(await statusPayload({ fresh: true }));
|
||||
} catch (e) {
|
||||
res.status(502).json({ message: e.message || safeErrorMessage(e), code: e.code, status: await statusPayload({ fresh: true }).catch(() => null) });
|
||||
}
|
||||
};
|
||||
|
||||
// @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 };
|
||||
@@ -0,0 +1,448 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { logDataAccess } = require('../utils/accessLog');
|
||||
const { findOrCreateAccount } = require('../utils/accountMatch');
|
||||
const { listWhere, withScope } = require('../utils/campusScope');
|
||||
const kids = require('../utils/kids');
|
||||
|
||||
const SUPERVISORS = ['admin', 'supervisor'];
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: safeErrorMessage(error), ...(error.code && typeof error.code === 'string' && !error.code.startsWith('P') ? { code: error.code } : {}) });
|
||||
const audit = (req, action, targetType, targetId, metadata) => logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action, targetType, targetId, metadata, ip: getClientIp(req) });
|
||||
|
||||
/** Child fields from a request; only supervisors, admins and the child's parents set custody notes. */
|
||||
function childInput(req, { parent = false } = {}) {
|
||||
const body = { ...(req.body || {}) };
|
||||
if (!parent && !SUPERVISORS.includes(req.user.role)) delete body.custodyNotes;
|
||||
return body;
|
||||
}
|
||||
|
||||
async function saveChild(req, familyId, childId, { parent }) {
|
||||
const existing = childId ? await prisma.child.findFirst({ where: { id: String(childId), familyId } }) : null;
|
||||
if (childId && !existing) throw kids.kidsError('Child not found', 404);
|
||||
const data = kids.cleanChild(childInput(req, { parent }), existing);
|
||||
const child = existing
|
||||
? await prisma.child.update({ where: { id: existing.id }, data })
|
||||
: await prisma.child.create({ data: { ...data, familyId } });
|
||||
if (kids.custodyChanged(existing, data)) audit(req, 'kids_custody_notes_changed', 'child', child.id, { familyId, by: parent ? 'parent' : 'staff' });
|
||||
return child;
|
||||
}
|
||||
|
||||
// ─── My account → Kids (parents) ──────────────────────────────────────────────
|
||||
|
||||
// @route GET /api/kids/me
|
||||
const getMine = async (req, res) => {
|
||||
try {
|
||||
const family = await kids.familyOfUser(req.user.id);
|
||||
if (!family) return res.json({ family: null, qrToken: null, codes: [], pages: [] });
|
||||
const active = await kids.activeForFamily(family.id);
|
||||
res.json({ family: kids.familyView(family), qrToken: family.qrToken, ...active });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/me { name }
|
||||
const createMine = async (req, res) => {
|
||||
try {
|
||||
const family = await kids.createFamily({ name: req.body?.name || `${req.user.name || 'My'} family`, userId: req.user.id });
|
||||
res.status(201).json({ family: kids.familyView(family), qrToken: family.qrToken, codes: [], pages: [] });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
async function myFamilyId(req) {
|
||||
const m = await prisma.familyMember.findUnique({ where: { userId: req.user.id } });
|
||||
if (!m) throw kids.kidsError('Add your family first.', 404);
|
||||
return m.familyId;
|
||||
}
|
||||
|
||||
// @route PUT /api/kids/me { name }
|
||||
const renameMine = async (req, res) => {
|
||||
try {
|
||||
const id = await myFamilyId(req);
|
||||
const name = String(req.body?.name || '').trim().slice(0, 100);
|
||||
if (!name) throw kids.kidsError('Enter a name for the family.');
|
||||
await prisma.family.update({ where: { id }, data: { name } });
|
||||
res.json(kids.familyView(await kids.loadFamily(id)));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/me/children, PUT /api/kids/me/children/:id
|
||||
const saveMyChild = async (req, res) => {
|
||||
try {
|
||||
const id = await myFamilyId(req);
|
||||
const child = await saveChild(req, id, req.params.id, { parent: true });
|
||||
res.status(req.params.id ? 200 : 201).json(kids.childView(child));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/kids/me/children/:id
|
||||
const deleteMyChild = async (req, res) => {
|
||||
try {
|
||||
const id = await myFamilyId(req);
|
||||
const out = await prisma.child.deleteMany({ where: { id: String(req.params.id), familyId: id } });
|
||||
if (!out.count) throw kids.kidsError('Child not found', 404);
|
||||
res.json({ message: 'Deleted' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/me/adults, PUT /api/kids/me/adults/:id
|
||||
const saveMyAdult = async (req, res) => {
|
||||
try {
|
||||
const id = await myFamilyId(req);
|
||||
const data = kids.cleanAdult(req.body);
|
||||
if (req.params.id) {
|
||||
const out = await prisma.authorisedAdult.updateMany({ where: { id: String(req.params.id), familyId: id }, data });
|
||||
if (!out.count) throw kids.kidsError('Not found', 404);
|
||||
return res.json(await prisma.authorisedAdult.findUnique({ where: { id: String(req.params.id) } }));
|
||||
}
|
||||
if (await prisma.authorisedAdult.count({ where: { familyId: id } }) >= 10) throw kids.kidsError('A family can list up to 10 authorised adults.');
|
||||
res.status(201).json(await prisma.authorisedAdult.create({ data: { ...data, familyId: id } }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/kids/me/adults/:id
|
||||
const deleteMyAdult = async (req, res) => {
|
||||
try {
|
||||
const id = await myFamilyId(req);
|
||||
const out = await prisma.authorisedAdult.deleteMany({ where: { id: String(req.params.id), familyId: id } });
|
||||
if (!out.count) throw kids.kidsError('Not found', 404);
|
||||
res.json({ message: 'Deleted' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/kids/me (leave the family; the last parent leaving deletes it)
|
||||
const leaveMine = async (req, res) => {
|
||||
try {
|
||||
await myFamilyId(req);
|
||||
const out = await prisma.$transaction(tx => kids.removeFromFamily(tx, req.user.id));
|
||||
res.json(out);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Staff: the desk and the rooms ────────────────────────────────────────────
|
||||
|
||||
// @desc Events with kids check-in that are on now or soon (and the last week's)
|
||||
// @route GET /api/kids/events
|
||||
const listEvents = async (req, res) => {
|
||||
try {
|
||||
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
const events = await prisma.event.findMany({
|
||||
where: withScope({ kidsCheckIn: true, endDate: { gte: since } }, await listWhere(req)),
|
||||
select: {
|
||||
id: true, title: true, startDate: true, endDate: true, scheduleType: true, campusId: true,
|
||||
sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' }, select: { id: true, label: true, startsAt: true, endsAt: true } },
|
||||
},
|
||||
orderBy: { startDate: 'asc' },
|
||||
take: 100,
|
||||
});
|
||||
res.json(events);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route GET /api/kids/events/:eventId/board?sessionId=&log=1
|
||||
const getBoard = async (req, res) => {
|
||||
try {
|
||||
const data = await kids.board({ eventId: req.params.eventId, sessionId: req.query.sessionId || null });
|
||||
// Polling refreshes don't log; opening the screen does (it shows medical and custody notes).
|
||||
if (req.query.log === '1') {
|
||||
logDataAccess(req, { action: 'kids_rooms', eventId: data.event.id, records: data.checkIns.length, sensitive: true });
|
||||
}
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route GET /api/kids/events/:eventId/families?q=
|
||||
const searchFamilies = async (req, res) => {
|
||||
try {
|
||||
const rows = await kids.searchFamilies(req.query.q);
|
||||
res.json(rows.map(f => kids.familyView(f)));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route GET /api/kids/events/:eventId/families/qr/:token
|
||||
const familyByQr = async (req, res) => {
|
||||
try {
|
||||
const raw = String(req.params.token || '').trim().replace(/^FAMILY-/i, '');
|
||||
const f = await prisma.family.findUnique({ where: { qrToken: raw } });
|
||||
if (!f) throw kids.kidsError('That isn\'t a family QR code from this site.', 404);
|
||||
res.json(kids.familyView(await kids.loadFamily(f.id)));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route GET /api/kids/events/:eventId/families/:familyId
|
||||
const getFamily = async (req, res) => {
|
||||
try {
|
||||
res.json(kids.familyView(await kids.loadFamily(req.params.familyId)));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
/** A parent from the desk: their account (matched by email/phone, or a new inactive one). */
|
||||
async function parentAccount(input) {
|
||||
const name = String(input?.name || '').trim().slice(0, 100);
|
||||
if (!name) throw kids.kidsError('Enter the parent\'s name.');
|
||||
const email = String(input?.email || '').trim();
|
||||
const phone = String(input?.phone || '').trim();
|
||||
if (!email && !phone) throw kids.kidsError('Enter the parent\'s phone number or email address, so the pickup code can be sent to them.');
|
||||
const { user } = await findOrCreateAccount({ name, email, phone });
|
||||
return user;
|
||||
}
|
||||
|
||||
// @desc New family at the desk: a parent (found or created) and their children
|
||||
// @route POST /api/kids/events/:eventId/families { name, parent: { name, phone, email }, children: [...] }
|
||||
const createFamilyAtDesk = async (req, res) => {
|
||||
try {
|
||||
const user = await parentAccount(req.body?.parent);
|
||||
const existing = await kids.familyOfUser(user.id);
|
||||
// Don't tell the desk anything about an account beyond what they typed: if the person is
|
||||
// already in a family, that family is used (they're standing at the desk).
|
||||
const family = existing || await kids.createFamily({ name: req.body?.name || `${user.name.split(' ').slice(-1)[0]} family`, userId: user.id });
|
||||
for (const c of (Array.isArray(req.body?.children) ? req.body.children : []).slice(0, 12)) {
|
||||
await saveChild({ ...req, body: c }, family.id, null, { parent: false });
|
||||
}
|
||||
res.status(existing ? 200 : 201).json(kids.familyView(await kids.loadFamily(family.id)));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/events/:eventId/families/:familyId/parents { name, phone, email }
|
||||
const addParentAtDesk = async (req, res) => {
|
||||
try {
|
||||
const family = await kids.loadFamily(req.params.familyId);
|
||||
const user = await parentAccount(req.body);
|
||||
const m = await prisma.familyMember.findUnique({ where: { userId: user.id } });
|
||||
if (m && m.familyId !== family.id) throw kids.kidsError('This person is already a parent in another family.', 409);
|
||||
if (!m) await prisma.familyMember.create({ data: { familyId: family.id, userId: user.id } });
|
||||
res.json(kids.familyView(await kids.loadFamily(family.id)));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/events/:eventId/families/:familyId/children, PUT .../children/:id
|
||||
const saveChildAtDesk = async (req, res) => {
|
||||
try {
|
||||
const family = await kids.loadFamily(req.params.familyId);
|
||||
const child = await saveChild(req, family.id, req.params.childId, { parent: false });
|
||||
res.status(req.params.childId ? 200 : 201).json(kids.childView(child));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/events/:eventId/families/:familyId/adults { name, phone, relationship }
|
||||
const addAdultAtDesk = async (req, res) => {
|
||||
try {
|
||||
const family = await kids.loadFamily(req.params.familyId);
|
||||
if (family.adults.length >= 10) throw kids.kidsError('A family can list up to 10 authorised adults.');
|
||||
await prisma.authorisedAdult.create({ data: { ...kids.cleanAdult(req.body), familyId: family.id } });
|
||||
res.status(201).json(kids.familyView(await kids.loadFamily(family.id)));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/events/:eventId/check-in { familyId, sessionId?, children: [{ childId, roomId? }], sendToAdultId? }
|
||||
const checkIn = async (req, res) => {
|
||||
try {
|
||||
const out = await kids.checkIn({
|
||||
eventId: req.params.eventId, sessionId: req.body?.sessionId || null, familyId: req.body?.familyId,
|
||||
children: req.body?.children, staffUserId: req.user.id,
|
||||
});
|
||||
const fresh = out.checkIns.filter(c => !out.already.includes(c.id));
|
||||
if (fresh.length) kids.sendCheckInMessages({ event: out.event, family: out.family, code: out.code, checkIns: fresh, adultId: req.body?.sendToAdultId || null }).catch(() => {});
|
||||
res.status(201).json({
|
||||
code: out.code,
|
||||
qr: kids.QR_PREFIX + out.code,
|
||||
occasion: out.occasion,
|
||||
family: { id: out.family.id, name: out.family.name },
|
||||
checkIns: out.checkIns.map(c => {
|
||||
const child = c.child ? kids.childView(c.child) : null;
|
||||
return {
|
||||
id: c.id, name: c.childName, firstName: child?.firstName || c.childName, room: c.room, already: out.already.includes(c.id),
|
||||
sticker: kids.stickerAlert(child?.allergies, child?.medicalNotes),
|
||||
};
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/events/:eventId/check-out { code, checkInIds?, collectedBy, roomId?, sessionId? }
|
||||
const checkOut = async (req, res) => {
|
||||
try {
|
||||
const out = await kids.checkOut({
|
||||
eventId: req.params.eventId, sessionId: req.body?.sessionId || null, code: req.body?.code, checkInIds: req.body?.checkInIds,
|
||||
collectedBy: req.body?.collectedBy, roomId: req.body?.roomId || null, staffUserId: req.user.id,
|
||||
});
|
||||
res.json({ checkedOut: out.checkIns.map(c => ({ id: c.id, name: c.childName, room: c.room?.name || null })) });
|
||||
} catch (error) {
|
||||
if (error.code === 'WRONG_CODE') audit(req, 'kids_wrong_code', 'event', req.params.eventId, { typed: String(req.body?.code || '').slice(0, 20), roomId: req.body?.roomId || null });
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Look up a code before checking out: which children are on it (to confirm who's collected)
|
||||
// @route GET /api/kids/events/:eventId/code/:code?sessionId=
|
||||
const lookupCode = async (req, res) => {
|
||||
try {
|
||||
const event = await kids.loadKidsEvent(req.params.eventId);
|
||||
const { occasion } = kids.occasionFor(event, event.sessions, req.query.sessionId || null);
|
||||
const code = kids.normaliseCode(req.params.code);
|
||||
const row = code ? await prisma.kidsPickupCode.findUnique({ where: { eventId_occasion_code: { eventId: event.id, occasion, code } } }) : null;
|
||||
if (!row) return res.json({ code, found: false, children: [] });
|
||||
const rows = await prisma.kidsCheckIn.findMany({ where: { pickupCodeId: row.id }, include: { room: { select: { id: true, name: true } } }, orderBy: { childName: 'asc' } });
|
||||
const family = row.familyId ? kids.familyView(await kids.loadFamily(row.familyId), { notes: false }) : null;
|
||||
res.json({
|
||||
code, found: true,
|
||||
family: family ? { name: family.name, parents: family.parents, adults: family.adults } : null,
|
||||
children: rows.map(c => ({ id: c.id, name: c.childName, room: c.room, checkedOutAt: c.checkedOutAt, collectedBy: c.collectedBy })),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/check-ins/:id/override { reason, collectedBy }
|
||||
const overrideCheckOut = async (req, res) => {
|
||||
try {
|
||||
const ci = await kids.overrideCheckOut({ checkInId: req.params.id, reason: req.body?.reason, collectedBy: req.body?.collectedBy, staffUserId: req.user.id });
|
||||
audit(req, 'kids_checkout_override', 'kids_check_in', ci.id, { child: ci.childName, collectedBy: ci.collectedBy, reason: ci.overrideReason });
|
||||
res.json({ id: ci.id, checkedOutAt: ci.checkedOutAt });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/check-ins/:id/page { message? }
|
||||
const pageParent = async (req, res) => {
|
||||
try {
|
||||
res.json(await kids.pageParent({ checkInId: req.params.id, message: req.body?.message, staffUserId: req.user.id }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route PUT /api/kids/check-ins/:id/room { roomId }
|
||||
const moveRoom = async (req, res) => {
|
||||
try {
|
||||
const ci = await kids.moveRoom({ checkInId: req.params.id, roomId: req.body?.roomId });
|
||||
res.json({ id: ci.id, roomId: ci.roomId });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/events/:eventId/duty { roomId, on, sessionId? }
|
||||
const setDuty = async (req, res) => {
|
||||
try {
|
||||
res.json(await kids.setDuty({ eventId: req.params.eventId, sessionId: req.body?.sessionId || null, roomId: req.body?.roomId, userId: req.user.id, on: req.body?.on !== false }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/alerts/:id/resolve
|
||||
const resolveAlert = async (req, res) => {
|
||||
try {
|
||||
const out = await prisma.kidsAlert.updateMany({ where: { id: String(req.params.id), resolvedAt: null }, data: { resolvedAt: new Date() } });
|
||||
res.json({ resolved: out.count });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route GET /api/kids/events/:eventId/offline?deviceId=&sessionId=
|
||||
const offlineSnapshot = async (req, res) => {
|
||||
try {
|
||||
const data = await kids.offlineSnapshot({ eventId: req.params.eventId, sessionId: req.query.sessionId || null, deviceId: req.query.deviceId });
|
||||
logDataAccess(req, { action: 'kids_offline', eventId: req.params.eventId, records: data.families.length, sensitive: true });
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/events/:eventId/sync { deviceId, occasion, checkIns, checkOuts }
|
||||
const syncOffline = async (req, res) => {
|
||||
try {
|
||||
const out = await kids.syncOffline({ eventId: req.params.eventId, ...(req.body || {}), staffUserId: req.user.id });
|
||||
if (out.checkedIn || out.checkedOut || out.conflicts.length) audit(req, 'kids_offline_sync', 'event', req.params.eventId, { checkedIn: out.checkedIn, checkedOut: out.checkedOut, conflicts: out.conflicts.length });
|
||||
res.json(out);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Supervisor: setup ────────────────────────────────────────────────────────
|
||||
|
||||
// @route GET /api/kids/events/:eventId/settings
|
||||
const getSettings = async (req, res) => {
|
||||
try {
|
||||
res.json(await kids.getSettings(req.params.eventId));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route PUT /api/kids/events/:eventId/settings { enabled?, optionId?, roomSetId? } ('new' makes one)
|
||||
const saveSettings = async (req, res) => {
|
||||
try {
|
||||
const { enabled, optionId, roomSetId } = req.body || {};
|
||||
const out = await kids.saveSettings(req.params.eventId, { enabled, optionId, roomSetId });
|
||||
audit(req, 'kids_settings_updated', 'event', out.event.id, { enabled: out.event.kidsCheckIn, optionId: out.event.kidsOptionId, roomSetId: out.event.kidsRoomSetId });
|
||||
res.json(out);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Events a supervisor can switch kids check-in on for (upcoming, newest first)
|
||||
// @route GET /api/kids/setup-events
|
||||
const setupEvents = async (req, res) => {
|
||||
try {
|
||||
const events = await prisma.event.findMany({
|
||||
where: withScope({ endDate: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } }, await listWhere(req)),
|
||||
select: { id: true, title: true, startDate: true, kidsCheckIn: true, campusId: true },
|
||||
orderBy: { startDate: 'asc' },
|
||||
take: 200,
|
||||
});
|
||||
res.json(events);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getMine, createMine, renameMine, saveMyChild, deleteMyChild, saveMyAdult, deleteMyAdult, leaveMine,
|
||||
listEvents, getBoard, searchFamilies, familyByQr, getFamily, createFamilyAtDesk, addParentAtDesk, saveChildAtDesk, addAdultAtDesk,
|
||||
checkIn, checkOut, lookupCode, overrideCheckOut, pageParent, moveRoom, setDuty, resolveAlert, offlineSnapshot, syncOffline,
|
||||
getSettings, saveSettings, setupEvents,
|
||||
};
|
||||
@@ -351,9 +351,13 @@ const getPayments = async (req, res) => {
|
||||
event: true
|
||||
};
|
||||
|
||||
// Campus-scoped supervisors: payments for events they can see (through the payment's event or
|
||||
// its registration's), so unlinked donations aren't listed for them.
|
||||
const scopeW = require('../utils/campusScope').eventWhere(await require('../utils/campusScope').scopeFor(req));
|
||||
const where = Object.keys(scopeW).length ? { OR: [{ event: scopeW }, { registration: { event: scopeW } }] } : {};
|
||||
const [payments, total] = await prisma.$transaction([
|
||||
prisma.payment.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.payment.count()
|
||||
prisma.payment.findMany({ where, include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.payment.count({ where })
|
||||
]);
|
||||
|
||||
res.json({ data: payments, total, page, limit, pages: Math.ceil(total / limit) });
|
||||
|
||||
@@ -261,6 +261,7 @@ const accessLog = async (req, res) => {
|
||||
try {
|
||||
const where = {};
|
||||
if (req.query.action) where.action = String(req.query.action);
|
||||
if (req.query.sensitive === 'true') where.sensitive = true;
|
||||
if (req.query.actorId) where.actorId = String(req.query.actorId);
|
||||
if (req.query.from || req.query.to) {
|
||||
where.createdAt = {};
|
||||
@@ -276,14 +277,161 @@ const accessLog = async (req, res) => {
|
||||
total, pageSize: PAGE_SIZE,
|
||||
actions: ACCESS_ACTIONS,
|
||||
actors: actors.map(a => ({ id: a.actorId, name: a.actor?.name || 'Unknown' })),
|
||||
rows: rows.map(r => ({ id: r.id, at: r.createdAt, actor: r.actor?.name || 'Deleted user', actorRole: r.actorRole, action: r.action, what: r.what, records: r.records, eventId: r.eventId, ip: r.ip })),
|
||||
rows: rows.map(r => ({ id: r.id, at: r.createdAt, actor: r.actor?.name || 'Deleted user', actorRole: r.actorRole, action: r.action, what: r.what, records: r.records, eventId: r.eventId, ip: r.ip, sensitive: r.sensitive })),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Readiness ────────────────────────────────────────────────────────────────
|
||||
|
||||
// @desc POPIA readiness checklist with a score
|
||||
// @route GET /api/privacy/readiness
|
||||
// @access Private/Admin
|
||||
const getReadiness = async (req, res) => {
|
||||
try {
|
||||
res.json(await require('../utils/readiness').readiness());
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Retention ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function retentionState() {
|
||||
const retention = require('../utils/retention');
|
||||
const { getSetting } = require('../utils/settingsCache');
|
||||
const current = await retention.getPolicies();
|
||||
const [preview, lastRun, medicalDays, backupCount] = await Promise.all([
|
||||
retention.preview(current.policies),
|
||||
retention.lastRun(),
|
||||
getSetting('consent_medical_retention_days', ''),
|
||||
getSetting('backup_retain_count', ''),
|
||||
]);
|
||||
return {
|
||||
...current,
|
||||
definitions: Object.fromEntries(Object.entries(retention.POLICIES).map(([k, d]) => [k, { label: d.label, description: d.description, minMonths: d.minMonths, defaults: d.defaults }])),
|
||||
preview,
|
||||
lastRun,
|
||||
related: { medicalDays: parseInt(medicalDays, 10) || 30, backupCount: parseInt(backupCount, 10) || 14, analyticsDays: 90 },
|
||||
};
|
||||
}
|
||||
|
||||
// @desc Retention policies, what they'd affect now, and the last run
|
||||
// @route GET /api/privacy/retention
|
||||
// @access Private/Admin
|
||||
const getRetention = async (req, res) => {
|
||||
try {
|
||||
res.json(await retentionState());
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Save retention policies (body.policies: { key: { enabled, months } })
|
||||
// @route PUT /api/privacy/retention
|
||||
// @access Private/Admin
|
||||
const saveRetention = async (req, res) => {
|
||||
try {
|
||||
const saved = await require('../utils/retention').savePolicies(req.body?.policies, { reviewedBy: req.user.name });
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'retention_policies_updated', targetType: 'retention', metadata: saved.policies, ip: getClientIp(req) });
|
||||
res.json(await retentionState());
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Run the retention job now
|
||||
// @route POST /api/privacy/retention/run
|
||||
// @access Private/Admin
|
||||
const runRetentionNow = async (req, res) => {
|
||||
try {
|
||||
const result = await require('../utils/retention').runRetention(new Date());
|
||||
res.json({ result, state: await retentionState() });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Breach register ──────────────────────────────────────────────────────────
|
||||
|
||||
const BREACH_TEXT = ['title', 'description', 'dataAffected', 'whoAffected', 'consequences', 'actionsTaken', 'adviceToPeople', 'causedBy'];
|
||||
const BREACH_DATES = ['discoveredAt', 'occurredAt', 'regulatorNotifiedAt', 'peopleNotifiedAt'];
|
||||
|
||||
function breachData(body, { partial = false } = {}) {
|
||||
const data = {};
|
||||
for (const f of BREACH_TEXT) {
|
||||
if (body?.[f] === undefined) continue;
|
||||
const v = body[f] == null ? '' : String(body[f]).trim().slice(0, f === 'title' ? 200 : 10000);
|
||||
data[f] = v || (['title', 'description', 'dataAffected'].includes(f) ? '' : null);
|
||||
}
|
||||
for (const f of BREACH_DATES) {
|
||||
if (body?.[f] === undefined) continue;
|
||||
if (body[f] === null || body[f] === '') { data[f] = null; continue; }
|
||||
const d = new Date(body[f]);
|
||||
if (Number.isNaN(d.getTime())) throw new Error(`${f} is not a valid date.`);
|
||||
data[f] = d;
|
||||
}
|
||||
if (body?.peopleAffected !== undefined) {
|
||||
const n = body.peopleAffected === null || body.peopleAffected === '' ? null : parseInt(body.peopleAffected, 10);
|
||||
if (n !== null && (!Number.isInteger(n) || n < 0)) throw new Error('People affected must be a whole number.');
|
||||
data.peopleAffected = n;
|
||||
}
|
||||
if (body?.status !== undefined) {
|
||||
if (!['open', 'contained', 'closed'].includes(body.status)) throw new Error('status must be open, contained or closed');
|
||||
data.status = body.status;
|
||||
}
|
||||
for (const f of ['title', 'description', 'dataAffected']) {
|
||||
if ((!partial || f in data) && !data[f]) throw new Error({ title: 'Give it a short title.', description: 'Describe what happened.', dataAffected: 'Say what information was affected.' }[f]);
|
||||
}
|
||||
if (!partial && !data.discoveredAt) throw new Error('When was it discovered?');
|
||||
if (partial && 'discoveredAt' in data && !data.discoveredAt) throw new Error('When was it discovered?');
|
||||
return data;
|
||||
}
|
||||
|
||||
// @route GET /api/privacy/breaches
|
||||
const listBreaches = async (req, res) => {
|
||||
try {
|
||||
res.json(await prisma.securityBreach.findMany({ orderBy: { discoveredAt: 'desc' }, include: { createdBy: { select: { name: true } } } }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/privacy/breaches
|
||||
const createBreach = async (req, res) => {
|
||||
try {
|
||||
const breach = await prisma.securityBreach.create({ data: { ...breachData(req.body), createdById: req.user.id } });
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'breach_recorded', targetType: 'security_breach', targetId: breach.id, metadata: { title: breach.title }, ip: getClientIp(req) });
|
||||
res.status(201).json(breach);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route PUT /api/privacy/breaches/:id
|
||||
const updateBreach = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.securityBreach.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) { res.status(404); throw new Error('Not found'); }
|
||||
const data = breachData(req.body, { partial: true });
|
||||
const breach = await prisma.securityBreach.update({ where: { id: existing.id }, data });
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'breach_updated', targetType: 'security_breach', targetId: breach.id, metadata: { title: breach.title, changed: Object.keys(data), status: breach.status }, ip: getClientIp(req) });
|
||||
res.json(breach);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getReadiness,
|
||||
getRetention,
|
||||
saveRetention,
|
||||
runRetentionNow,
|
||||
listBreaches,
|
||||
createBreach,
|
||||
updateBreach,
|
||||
getNotice,
|
||||
acceptMyNotice,
|
||||
getNoticeDraft,
|
||||
|
||||
@@ -13,6 +13,7 @@ const { findAccountByContact } = require('../utils/accountMatch');
|
||||
const { ensureConsentRequests } = require('../utils/consent');
|
||||
const { takenCount } = require('../utils/stock');
|
||||
const { findOfferByToken, queueWaitlist } = require('../utils/waitlist');
|
||||
const { reservedOptionIds } = require('../utils/volunteers');
|
||||
|
||||
// Youth events: send parental consent requests for any new under-18 attendees. Runs in
|
||||
// the background so a slow mail/WhatsApp send never holds up the registration.
|
||||
@@ -237,9 +238,11 @@ const createRegistration = async (req, res) => {
|
||||
// Options are independent of each other, so resolve them concurrently. Errors are marked
|
||||
// with `.isStockError` rather than relying on shared `res.statusCode` (which would race
|
||||
// across concurrent iterations) — the outer catch always responds 400 regardless.
|
||||
// The kids check-in and volunteer tickets are handed out by staff, never sold.
|
||||
const reserved = new Set(isStaffRole(req.user?.role) ? [] : reservedOptionIds(event));
|
||||
const resolvedOptions = await Promise.all(lines.map(async (option) => {
|
||||
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
|
||||
if (!eventOption) {
|
||||
if (!eventOption || reserved.has(eventOption.id)) {
|
||||
throw new Error(`Option with ID ${option.eventOptionId} not found for this event`);
|
||||
}
|
||||
if (!option.quantity || option.quantity < 1) {
|
||||
@@ -565,8 +568,10 @@ const getRegistrations = async (req, res) => {
|
||||
// Pagination is opt-in via ?page/?limit to keep existing callers (which expect a plain
|
||||
// array of every registration) working unchanged; callers that pass either param get back
|
||||
// the { data, total, page, limit, pages } shape used by /api/payments and /api/users.
|
||||
// Campus-scoped staff: registrations for events they can see.
|
||||
const where = await require('../utils/campusScope').listWhere(req, 'event');
|
||||
if (typeof req.query.page === 'undefined' && typeof req.query.limit === 'undefined') {
|
||||
const registrations = await prisma.registration.findMany({ include });
|
||||
const registrations = await prisma.registration.findMany({ where, include });
|
||||
return res.json(attachComputedTotalsToList(registrations));
|
||||
}
|
||||
|
||||
@@ -575,8 +580,8 @@ const getRegistrations = async (req, res) => {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [registrations, total] = await prisma.$transaction([
|
||||
prisma.registration.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.registration.count()
|
||||
prisma.registration.findMany({ where, include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.registration.count({ where })
|
||||
]);
|
||||
|
||||
res.json({ data: attachComputedTotalsToList(registrations), total, page, limit, pages: Math.ceil(total / limit) });
|
||||
@@ -1505,6 +1510,18 @@ const updateRegistrationOptions = async (req, res) => {
|
||||
throw new Error("Sessions are booked by registering for them — they can't be changed here.");
|
||||
}
|
||||
}
|
||||
// The kids check-in and volunteer tickets are handed out by staff: the owner can keep (or
|
||||
// lower) the places they were given, not add more.
|
||||
if (!isStaffRole(req.user.role)) {
|
||||
for (const id of reservedOptionIds(registration.event)) {
|
||||
const had = registration.registrationOptions.filter(ro => ro.eventOptionId === id).reduce((n, ro) => n + (ro.quantity || 0), 0);
|
||||
const asked = options.filter(o => o.eventOptionId === id).reduce((n, o) => n + o.quantity, 0);
|
||||
if (asked > had) {
|
||||
res.status(400);
|
||||
throw new Error('One or more options do not belong to this event');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Picked-session lines aren't edited here: leave them exactly as they are (they still
|
||||
// count towards the total due).
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
const { listJobs, getJob, updateJob, deleteJob } = require('../utils/scheduledEmails');
|
||||
const prisma = require('../config/db');
|
||||
const { scopeFor, eventWhere } = require('../utils/campusScope');
|
||||
|
||||
/**
|
||||
* The jobs a campus-scoped supervisor may see and change: messages for events they can see, and
|
||||
* broadcasts they scheduled themselves. Everyone else sees every job.
|
||||
*/
|
||||
async function visibleJobs(req, jobs) {
|
||||
const scope = await scopeFor(req);
|
||||
if (!scope) return jobs;
|
||||
const ids = [...new Set(jobs.map(j => j.eventId).filter(Boolean))];
|
||||
const seen = ids.length
|
||||
? new Set((await prisma.event.findMany({ where: { id: { in: ids }, ...eventWhere(scope) }, select: { id: true } })).map(e => e.id))
|
||||
: new Set();
|
||||
return jobs.filter(j => (j.eventId ? seen.has(j.eventId) : j.createdById === req.user.id));
|
||||
}
|
||||
|
||||
// Normalize job for client UI
|
||||
function toClient(job) {
|
||||
@@ -34,7 +50,7 @@ function toClient(job) {
|
||||
const listScheduledEmails = async (req, res) => {
|
||||
try {
|
||||
const { channel } = req.query || {};
|
||||
const raw = listJobs(channel ? { channel: String(channel) } : {});
|
||||
const raw = await visibleJobs(req, listJobs(channel ? { channel: String(channel) } : {}));
|
||||
const filtered = raw
|
||||
// Provide most-relevant first: queued -> sending -> error -> recent sent
|
||||
.sort((a, b) => {
|
||||
@@ -59,7 +75,7 @@ const updateScheduledEmail = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const job = getJob(id);
|
||||
if (!job) return res.status(404).json({ message: 'Job not found' });
|
||||
if (!job || !(await visibleJobs(req, [job])).length) return res.status(404).json({ message: 'Job not found' });
|
||||
if (job.status !== 'queued') return res.status(400).json({ message: 'Only queued jobs can be edited' });
|
||||
|
||||
const { scheduledAt, subject, html, text, message } = req.body || {};
|
||||
@@ -92,7 +108,7 @@ const deleteScheduledEmail = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const job = getJob(id);
|
||||
if (!job) return res.status(404).json({ message: 'Job not found' });
|
||||
if (!job || !(await visibleJobs(req, [job])).length) return res.status(404).json({ message: 'Job not found' });
|
||||
if (job.status !== 'queued') return res.status(400).json({ message: 'Only queued jobs can be deleted' });
|
||||
const ok = deleteJob(id);
|
||||
if (!ok) return res.status(404).json({ message: 'Job not found' });
|
||||
|
||||
@@ -10,9 +10,8 @@ const getSections = async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.query;
|
||||
|
||||
const where = eventId && eventId !== 'all'
|
||||
? { eventId }
|
||||
: undefined;
|
||||
const { withScope, listWhere } = require('../utils/campusScope');
|
||||
const where = withScope(eventId && eventId !== 'all' ? { eventId } : {}, await listWhere(req, 'event'));
|
||||
|
||||
const sections = await prisma.section.findMany({
|
||||
where,
|
||||
|
||||
@@ -15,6 +15,13 @@ const PAYMENT_PRIVATE_KEYS = new Set([
|
||||
'payfast_merchant_id', 'payfast_merchant_key', 'payfast_passphrase', 'payfast_mode',
|
||||
]);
|
||||
|
||||
// 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);
|
||||
// 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',
|
||||
// Branding — primary_color/secondary_color/accent_color are the 3-color brand
|
||||
@@ -53,7 +60,7 @@ const getAllSettings = async (req, res) => {
|
||||
const rows = await prisma.appSetting.findMany();
|
||||
const settings = {};
|
||||
for (const r of rows) {
|
||||
if (PAYMENT_PRIVATE_KEYS.has(r.key)) continue;
|
||||
if (PRIVATE_KEYS.has(r.key)) continue;
|
||||
if (ENCRYPTED_KEYS.has(r.key)) {
|
||||
// Return a sentinel so the UI knows the value is set, without exposing it.
|
||||
// smtp_user (email address) we can safely return as-is after decryption so
|
||||
@@ -89,7 +96,7 @@ const updateSettings = async (req, res) => {
|
||||
}
|
||||
|
||||
const ops = Object.entries(updates)
|
||||
.filter(([k, v]) => v !== undefined && v !== null && !PAYMENT_PRIVATE_KEYS.has(k))
|
||||
.filter(([k, v]) => v !== undefined && v !== null && !PRIVATE_KEYS.has(k))
|
||||
.map(([key, value]) => {
|
||||
let storedValue = String(value);
|
||||
|
||||
@@ -128,7 +135,7 @@ const updateSettings = async (req, res) => {
|
||||
actorRole: req.user.role,
|
||||
action: 'settings_updated',
|
||||
targetType: 'AppSetting',
|
||||
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !PAYMENT_PRIVATE_KEYS.has(k)) },
|
||||
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !PRIVATE_KEYS.has(k)) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
@@ -205,7 +212,7 @@ const runSetup = async (req, res) => {
|
||||
|
||||
const toSave = { ...settings, setup_complete: 'true' };
|
||||
const ops = Object.entries(toSave)
|
||||
.filter(([k, v]) => v !== undefined && v !== null && String(v).trim() !== '' && !PAYMENT_PRIVATE_KEYS.has(k))
|
||||
.filter(([k, v]) => v !== undefined && v !== null && String(v).trim() !== '' && !PRIVATE_KEYS.has(k))
|
||||
.map(([key, value]) => {
|
||||
let storedValue = String(value);
|
||||
if (ENCRYPTED_KEYS.has(key)) storedValue = encrypt(storedValue);
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { isDonationLeg } = require('../utils/cashupUtils');
|
||||
const { campusesEnabled, campusWhere, resolveCampusFilter } = require('../utils/campuses');
|
||||
const { scopeFor, eventWhere: scopeEventWhere, withScope } = require('../utils/campusScope');
|
||||
|
||||
// Shared building blocks for the per-dashboard stats endpoints below. Each dashboard
|
||||
// (staff/supervisor/admin) gets exactly one endpoint that returns only what it renders,
|
||||
// computed with aggregate queries — never a full payments/events list shipped to the
|
||||
// client just to be reduced down to a couple of numbers.
|
||||
|
||||
async function computeScanStats(userId) {
|
||||
async function computeScanStats(userId, ticketWhere = {}) {
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const whereBase = { scannedAt: { gte: startOfDay } };
|
||||
const whereBase = { scannedAt: { gte: startOfDay }, ...ticketWhere };
|
||||
|
||||
const [totalToday, myToday, lastHour, byStaffRaw] = await Promise.all([
|
||||
prisma.ticketUsage.count({ where: whereBase }),
|
||||
prisma.ticketUsage.count({ where: { ...whereBase, scannedById: userId } }),
|
||||
prisma.ticketUsage.count({ where: { scannedAt: { gte: new Date(Date.now() - 60 * 60 * 1000) } } }),
|
||||
prisma.ticketUsage.count({ where: { scannedAt: { gte: new Date(Date.now() - 60 * 60 * 1000) }, ...ticketWhere } }),
|
||||
prisma.ticketUsage.groupBy({ by: ['scannedById'], where: whereBase, _count: { _all: true } }),
|
||||
]);
|
||||
|
||||
@@ -33,8 +35,9 @@ async function computeScanStats(userId) {
|
||||
};
|
||||
}
|
||||
|
||||
function getRecentScans(limit = 10) {
|
||||
function getRecentScans(limit = 10, ticketWhere = {}) {
|
||||
return prisma.ticketUsage.findMany({
|
||||
where: ticketWhere,
|
||||
orderBy: { scannedAt: 'desc' },
|
||||
take: limit,
|
||||
select: {
|
||||
@@ -53,8 +56,26 @@ function getRecentScans(limit = 10) {
|
||||
});
|
||||
}
|
||||
|
||||
function getActiveEventsCount() {
|
||||
return prisma.event.count({ where: { isActive: true, endDate: { gte: new Date() } } });
|
||||
function getActiveEventsCount(eventWhere = {}) {
|
||||
return prisma.event.count({ where: { isActive: true, endDate: { gte: new Date() }, ...eventWhere } });
|
||||
}
|
||||
|
||||
/**
|
||||
* `where` pieces for a ?campus= filter (id, slug or 'all') together with the user's campus scope
|
||||
* (1.44.0), or empty for church-wide. A payment belongs to a campus through its event, or its
|
||||
* registration's event — so payments with neither (unlinked donations) aren't counted for a
|
||||
* scoped user. EXCLUDE_DONATION_LEGS uses NOT, so the payment OR never collides with it.
|
||||
*/
|
||||
async function campusFilters(value, scope = null) {
|
||||
const enabled = await campusesEnabled();
|
||||
const eventWhere = withScope(campusWhere(enabled ? await resolveCampusFilter(value) : '', { enabled }), scopeEventWhere(scope));
|
||||
if (!Object.keys(eventWhere).length) return { eventWhere: {}, byEvent: {}, payment: {}, usage: {} };
|
||||
return {
|
||||
eventWhere,
|
||||
byEvent: { event: eventWhere },
|
||||
payment: { OR: [{ event: eventWhere }, { registration: { event: eventWhere } }] },
|
||||
usage: { ticket: { event: eventWhere } },
|
||||
};
|
||||
}
|
||||
|
||||
// A donation-application leg (isDonation:false, originalPaymentId set, amount>0) isn't new
|
||||
@@ -64,19 +85,19 @@ const EXCLUDE_DONATION_LEGS = {
|
||||
NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] },
|
||||
};
|
||||
|
||||
async function computePaymentStats({ includeWeekMonth }) {
|
||||
async function computePaymentStats({ includeWeekMonth, paymentWhere = {} }) {
|
||||
const startOfDay = new Date(new Date().setHours(0, 0, 0, 0));
|
||||
|
||||
const queries = [
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: startOfDay }, ...EXCLUDE_DONATION_LEGS } }),
|
||||
prisma.payment.count({ where: { isDonation: true, createdAt: { gte: startOfDay } } }),
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: startOfDay }, ...EXCLUDE_DONATION_LEGS, ...paymentWhere } }),
|
||||
prisma.payment.count({ where: { isDonation: true, createdAt: { gte: startOfDay }, ...paymentWhere } }),
|
||||
];
|
||||
if (includeWeekMonth) {
|
||||
const lastWeek = new Date(startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const lastMonth = new Date(startOfDay.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
queries.push(
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastWeek }, ...EXCLUDE_DONATION_LEGS } }),
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth }, ...EXCLUDE_DONATION_LEGS } }),
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastWeek }, ...EXCLUDE_DONATION_LEGS, ...paymentWhere } }),
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth }, ...EXCLUDE_DONATION_LEGS, ...paymentWhere } }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,9 +119,10 @@ async function computePaymentStats({ includeWeekMonth }) {
|
||||
// @access Private/Staff+
|
||||
const getStaffDashboardStats = async (req, res) => {
|
||||
try {
|
||||
const cf = await campusFilters('', await scopeFor(req));
|
||||
const [scanStats, recentScans] = await Promise.all([
|
||||
computeScanStats(req.user.id),
|
||||
getRecentScans(10),
|
||||
computeScanStats(req.user.id, cf.usage),
|
||||
getRecentScans(10, cf.usage),
|
||||
]);
|
||||
res.json({ scanStats, recentScans });
|
||||
} catch (error) {
|
||||
@@ -113,11 +135,12 @@ const getStaffDashboardStats = async (req, res) => {
|
||||
// @access Private/Supervisor+
|
||||
const getSupervisorDashboardStats = async (req, res) => {
|
||||
try {
|
||||
const cf = await campusFilters('', await scopeFor(req));
|
||||
const [scanStats, recentScans, paymentStats, activeEventsCount] = await Promise.all([
|
||||
computeScanStats(req.user.id),
|
||||
getRecentScans(10),
|
||||
computePaymentStats({ includeWeekMonth: false }),
|
||||
getActiveEventsCount(),
|
||||
computeScanStats(req.user.id, cf.usage),
|
||||
getRecentScans(10, cf.usage),
|
||||
computePaymentStats({ includeWeekMonth: false, paymentWhere: cf.payment }),
|
||||
getActiveEventsCount(cf.eventWhere),
|
||||
]);
|
||||
res.json({ scanStats, recentScans, paymentStats, activeEventsCount });
|
||||
} catch (error) {
|
||||
@@ -207,21 +230,22 @@ function classifyAndSum(payments) {
|
||||
// @desc Registrations/tickets/revenue/donations for the trailing month (today back one
|
||||
// month) vs the month before that, a daily revenue trend for the trailing month,
|
||||
// and the top performing events overall.
|
||||
// @route GET /api/stats/overview
|
||||
// @route GET /api/stats/overview?campus=<id|slug|all> (no campus = church-wide)
|
||||
// @access Private/Supervisor+
|
||||
const getOverviewStats = async (req, res) => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const { thisStart, thisEnd, lastStart, lastEnd } = trailingMonthRanges(now);
|
||||
const cf = await campusFilters(req.query.campus, await scopeFor(req));
|
||||
|
||||
const [thisPayments, lastPayments, thisRegistrations, lastRegistrations, thisTickets, lastTickets, activeEvents] = await Promise.all([
|
||||
fetchPaymentsWithOriginal({ createdAt: { gte: thisStart, lt: thisEnd } }),
|
||||
fetchPaymentsWithOriginal({ createdAt: { gte: lastStart, lt: lastEnd } }),
|
||||
prisma.registration.count({ where: { createdAt: { gte: thisStart, lt: thisEnd }, status: { not: 'cancelled' } } }),
|
||||
prisma.registration.count({ where: { createdAt: { gte: lastStart, lt: lastEnd }, status: { not: 'cancelled' } } }),
|
||||
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: thisStart, lt: thisEnd } } }),
|
||||
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: lastStart, lt: lastEnd } } }),
|
||||
getActiveEventsCount(),
|
||||
fetchPaymentsWithOriginal({ createdAt: { gte: thisStart, lt: thisEnd }, ...cf.payment }),
|
||||
fetchPaymentsWithOriginal({ createdAt: { gte: lastStart, lt: lastEnd }, ...cf.payment }),
|
||||
prisma.registration.count({ where: { createdAt: { gte: thisStart, lt: thisEnd }, status: { not: 'cancelled' }, ...cf.byEvent } }),
|
||||
prisma.registration.count({ where: { createdAt: { gte: lastStart, lt: lastEnd }, status: { not: 'cancelled' }, ...cf.byEvent } }),
|
||||
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: thisStart, lt: thisEnd }, ...cf.byEvent } }),
|
||||
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: lastStart, lt: lastEnd }, ...cf.byEvent } }),
|
||||
getActiveEventsCount(cf.eventWhere),
|
||||
]);
|
||||
|
||||
const thisTotals = classifyAndSum(thisPayments);
|
||||
@@ -245,7 +269,7 @@ const getOverviewStats = async (req, res) => {
|
||||
// "real revenue" definition as the KPI above: everything except application legs.
|
||||
const revenueByEvent = await prisma.payment.groupBy({
|
||||
by: ['eventId'],
|
||||
where: { ...EXCLUDE_DONATION_LEGS, eventId: { not: null } },
|
||||
where: { ...EXCLUDE_DONATION_LEGS, eventId: { not: null }, ...cf.byEvent },
|
||||
_sum: { amount: true },
|
||||
orderBy: { _sum: { amount: 'desc' } },
|
||||
take: 5,
|
||||
@@ -270,7 +294,7 @@ const getOverviewStats = async (req, res) => {
|
||||
// Funnel conversion (event page views → paid) across live events, last 30 days.
|
||||
let funnel = null;
|
||||
try {
|
||||
const live = await prisma.event.findMany({ where: { isActive: true, endDate: { gte: now } }, select: { id: true } });
|
||||
const live = await prisma.event.findMany({ where: { isActive: true, endDate: { gte: now }, ...cf.eventWhere }, select: { id: true } });
|
||||
if (live.length) {
|
||||
const { funnelFor } = require('../utils/analytics');
|
||||
const f = await funnelFor(live.map(e => e.id), { from: new Date(now.getTime() - 30 * 864e5).toISOString() });
|
||||
@@ -296,6 +320,7 @@ const getOverviewStats = async (req, res) => {
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
campusFilters,
|
||||
getStaffDashboardStats,
|
||||
getSupervisorDashboardStats,
|
||||
getAdminDashboardStats,
|
||||
|
||||
@@ -50,6 +50,8 @@ const getTickets = async (req, res) => {
|
||||
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 100));
|
||||
const skip = (page - 1) * limit;
|
||||
// Campus-scoped supervisors: tickets for events they can see.
|
||||
const scopeW = await require('../utils/campusScope').listWhere(req, 'event');
|
||||
|
||||
const include = {
|
||||
registrationOption: {
|
||||
@@ -66,8 +68,8 @@ const getTickets = async (req, res) => {
|
||||
};
|
||||
|
||||
const [tickets, total] = await prisma.$transaction([
|
||||
prisma.ticket.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.ticket.count()
|
||||
prisma.ticket.findMany({ where: scopeW, include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.ticket.count({ where: scopeW })
|
||||
]);
|
||||
|
||||
res.json({ data: tickets, total, page, limit, pages: Math.ceil(total / limit) });
|
||||
@@ -103,7 +105,7 @@ const getUserTickets = async (req, res) => {
|
||||
},
|
||||
},
|
||||
user: { select: { id: true, name: true } },
|
||||
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } },
|
||||
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } }, campus: { select: { name: true, slug: true } } } },
|
||||
transferRequests: { where: { status: 'pending' }, select: { id: true, toName: true, expiresAt: true } },
|
||||
usages: {
|
||||
include: {
|
||||
@@ -632,9 +634,11 @@ const getRecentScans = async (req, res) => {
|
||||
try {
|
||||
const limit = Math.max(1, Math.min(parseInt(req.query.limit) || 10, 100));
|
||||
const eventId = req.query.eventId || undefined;
|
||||
// Campus-scoped staff: scans of tickets for events they can see.
|
||||
const scopeEv = (await require('../utils/campusScope').listWhere(req, 'event')).event;
|
||||
|
||||
const scans = await prisma.ticketUsage.findMany({
|
||||
where: eventId ? { ticket: { eventId } } : undefined,
|
||||
where: eventId || scopeEv ? { ticket: { ...(eventId ? { eventId } : {}), ...(scopeEv ? { event: scopeEv } : {}) } } : undefined,
|
||||
orderBy: { scannedAt: 'desc' },
|
||||
take: limit,
|
||||
select: {
|
||||
@@ -666,11 +670,14 @@ const getScanStats = async (req, res) => {
|
||||
const eventId = req.query.eventId || undefined;
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
// Campus-scoped staff: scans of tickets for events they can see.
|
||||
const scopeEv = (await require('../utils/campusScope').listWhere(req, 'event')).event;
|
||||
const ticketWhere = eventId || scopeEv ? { ticket: { ...(eventId ? { eventId } : {}), ...(scopeEv ? { event: scopeEv } : {}) } } : {};
|
||||
|
||||
// Total scans today (optionally by event)
|
||||
const whereBase = {
|
||||
scannedAt: { gte: startOfDay },
|
||||
...(eventId ? { ticket: { eventId } } : {})
|
||||
...ticketWhere
|
||||
};
|
||||
|
||||
const [totalToday, myToday, lastHour] = await Promise.all([
|
||||
@@ -678,7 +685,7 @@ const getScanStats = async (req, res) => {
|
||||
prisma.ticketUsage.count({ where: { ...whereBase, scannedById: req.user.id } }),
|
||||
prisma.ticketUsage.count({
|
||||
where: {
|
||||
...(eventId ? { ticket: { eventId } } : {}),
|
||||
...ticketWhere,
|
||||
scannedAt: { gte: new Date(Date.now() - 60 * 60 * 1000) }
|
||||
}
|
||||
})
|
||||
@@ -929,6 +936,12 @@ function drawTicketCard(doc, { x, y, w, h }, ticket, qrDataUrl, buyerName) {
|
||||
doc.text(ticket.event.title, x + pad, cy, { width: titleW });
|
||||
cy += doc.heightOfString(ticket.event.title, { width: titleW }) + 4;
|
||||
|
||||
// Campus (only included while campuses are on)
|
||||
if (ticket.event.campus?.name) {
|
||||
doc.font('Helvetica').fontSize(9).fillColor(gray500).text(ticket.event.campus.name, x + pad, cy, { width: innerW });
|
||||
cy += doc.heightOfString(ticket.event.campus.name, { width: innerW }) + 2;
|
||||
}
|
||||
|
||||
// Ticket type
|
||||
const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : '';
|
||||
const type = `${ticket.registrationOption.eventOption.name}${variantSuffix}`;
|
||||
@@ -983,8 +996,9 @@ async function emailTicketsInternal(req, res, userOverride) {
|
||||
return res.json({ message: 'No valid contact details on file — ticket delivery skipped.' });
|
||||
}
|
||||
|
||||
const showCampus = await require('../utils/campuses').campusesEnabled();
|
||||
const ticketInclude = {
|
||||
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } },
|
||||
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } }, ...(showCampus ? { campus: { select: { name: true } } } : {}) } },
|
||||
registrationOption: { include: { eventOption: true, variant: true, session: true, registration: { select: { ticketDelivery: true } } } },
|
||||
attendee: { include: { allocations: { where: { set: { showOnTicket: true } }, include: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } } } },
|
||||
user: { select: { name: true } },
|
||||
|
||||
@@ -153,6 +153,8 @@ function errorStatus(error, res) {
|
||||
// required): notify, record the activity, and issue the session token.
|
||||
async function completeLogin(user, req, res, extra = {}) {
|
||||
const noticePromptPending = await require('../utils/privacyNotice').noticePromptPending(user).catch(() => false);
|
||||
// Retention: logging in keeps the account (and cancels an inactivity warning).
|
||||
prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date(), inactivityWarnedAt: null } }).catch(() => {});
|
||||
|
||||
// Send login notification in the background
|
||||
sendLoginNotification(user, req).catch(() => {});
|
||||
@@ -535,6 +537,7 @@ const getUserProfile = async (req, res) => {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true,
|
||||
homeCampusId: true,
|
||||
newsAskedAt: true,
|
||||
noticeVersionAccepted: true
|
||||
}
|
||||
@@ -545,7 +548,9 @@ const getUserProfile = async (req, res) => {
|
||||
// newsPromptPending: they haven't answered the news opt-in question yet (asked once).
|
||||
// noticePromptPending: a privacy notice version they must accept has been published.
|
||||
const noticePromptPending = !req.impersonation && await require('../utils/privacyNotice').noticePromptPending({ noticeVersionAccepted });
|
||||
res.json({ ...rest, twoFactorRequired: requiresTwoFactor(user), newsPromptPending: !newsAskedAt && !req.impersonation, noticePromptPending });
|
||||
// Campus-scoped staff: the campuses they're limited to (null = every campus).
|
||||
const campusScope = (await require('../utils/campusScope').scopeFor(req))?.campusIds || null;
|
||||
res.json({ ...rest, twoFactorRequired: requiresTwoFactor(user), newsPromptPending: !newsAskedAt && !req.impersonation, noticePromptPending, campusScope });
|
||||
} else {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
@@ -569,7 +574,18 @@ const updateUserProfile = async (req, res) => {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const { name, email, password, phoneNumber, currentPassword, notificationPreference } = req.body || {};
|
||||
const { name, email, password, phoneNumber, currentPassword, notificationPreference, homeCampusId } = req.body || {};
|
||||
|
||||
// Home campus (only while campuses are on): an active campus, or ''/null for none.
|
||||
let newHomeCampusId = user.homeCampusId;
|
||||
if (homeCampusId !== undefined && await require('../utils/campuses').campusesEnabled()) {
|
||||
if (!homeCampusId) newHomeCampusId = null;
|
||||
else {
|
||||
const campus = await prisma.campus.findFirst({ where: { id: String(homeCampusId), archivedAt: null }, select: { id: true } });
|
||||
if (!campus) { res.status(400); throw new Error('Choose one of the campuses in the list.'); }
|
||||
newHomeCampusId = campus.id;
|
||||
}
|
||||
}
|
||||
|
||||
// If a password change is requested, verify current password for safety
|
||||
let newHashedPassword = undefined;
|
||||
@@ -617,6 +633,7 @@ const updateUserProfile = async (req, res) => {
|
||||
phoneNumber: newPhone,
|
||||
notificationPreference: newPref,
|
||||
twoFactorMethod: newTwoFactorMethod,
|
||||
homeCampusId: newHomeCampusId,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
select: {
|
||||
@@ -631,6 +648,7 @@ const updateUserProfile = async (req, res) => {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true,
|
||||
homeCampusId: true,
|
||||
tokenVersion: true
|
||||
}
|
||||
});
|
||||
@@ -767,7 +785,8 @@ const getUserById = async (req, res) => {
|
||||
phoneNumber: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
scopedCampuses: { select: { id: true, name: true } }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -796,7 +815,7 @@ const updateUser = async (req, res) => {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const { name, email, role, isActive, phoneNumber, password, notificationPreference, twoFactorEnabled } = req.body;
|
||||
const { name, email, role, isActive, phoneNumber, password, notificationPreference, twoFactorEnabled, scopedCampusIds } = req.body;
|
||||
|
||||
const newPhone = phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber;
|
||||
const { resolveNotificationPreference } = require('../utils/notificationPreference');
|
||||
@@ -832,6 +851,21 @@ const updateUser = async (req, res) => {
|
||||
data.tokenVersion = { increment: 1 };
|
||||
}
|
||||
|
||||
// Campus-scoped staff (utils/campusScope.js): "Limit to campuses" for supervisors and staff.
|
||||
// Admins and users are never limited, so a role change away from staff clears the limit.
|
||||
const before = await prisma.user.findUnique({ where: { id: user.id }, select: { scopedCampuses: { select: { id: true } } } });
|
||||
const beforeIds = before.scopedCampuses.map(c => c.id).sort();
|
||||
let afterIds = beforeIds;
|
||||
if (!['supervisor', 'staff'].includes(data.role)) afterIds = [];
|
||||
else if (Array.isArray(scopedCampusIds)) {
|
||||
const wanted = [...new Set(scopedCampusIds.filter(x => typeof x === 'string' && x))];
|
||||
const found = await prisma.campus.findMany({ where: { id: { in: wanted } }, select: { id: true } });
|
||||
if (found.length !== wanted.length) { res.status(400); throw new Error('One of those campuses doesn\'t exist.'); }
|
||||
afterIds = wanted.sort();
|
||||
}
|
||||
const scopeChanged = afterIds.join(',') !== beforeIds.join(',');
|
||||
if (scopeChanged) data.scopedCampuses = { set: afterIds.map(id => ({ id })) };
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data,
|
||||
@@ -845,9 +879,13 @@ const updateUser = async (req, res) => {
|
||||
twoFactorEnabled: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
scopedCampuses: { select: { id: true, name: true } }
|
||||
}
|
||||
});
|
||||
if (scopeChanged) {
|
||||
require('../utils/adminAudit').logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'user_campus_scope_changed', targetType: 'User', targetId: updatedUser.id, metadata: { from: beforeIds, to: afterIds }, ip: getClientIp(req) });
|
||||
}
|
||||
|
||||
if (resetTwoFactor || data.password) {
|
||||
await clearTrustedDevices(updatedUser.id);
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { logDataAccess } = require('../utils/accessLog');
|
||||
const { listWhere, withScope } = require('../utils/campusScope');
|
||||
const v = require('../utils/volunteers');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: safeErrorMessage(error), ...(error.code && typeof error.code === 'string' && !error.code.startsWith('P') ? { code: error.code } : {}) });
|
||||
const audit = (req, action, targetType, targetId, metadata) => logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action, targetType, targetId, metadata, ip: getClientIp(req) });
|
||||
const later = p => { Promise.resolve(p).catch(() => {}); };
|
||||
const CONTACT = { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true };
|
||||
|
||||
// ─── My account → Serving (anyone signed in) ──────────────────────────────────
|
||||
|
||||
// @desc My shifts, cover requests I could take, and events looking for volunteers
|
||||
// @route GET /api/volunteers/me
|
||||
const getMine = async (req, res) => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const [slots, cover, events] = await Promise.all([
|
||||
v.mySlots({ userId: req.user.id, now }),
|
||||
v.openSwaps({ userId: req.user.id, now }),
|
||||
v.eventsWithRoles({ now, where: { isHidden: false, goLiveAt: { lte: now } } }),
|
||||
]);
|
||||
res.json({ slots, cover, events: events.map(e => ({ id: e.id, title: e.title, startDate: e.startDate, endDate: e.endDate, location: e.location, roles: e._count.servingRoles })) });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc An event's serving roles: how full each is, and whether I'm in it
|
||||
// @route GET /api/volunteers/events/:eventId
|
||||
const getEventRoles = async (req, res) => {
|
||||
try {
|
||||
const out = await v.roster(req.params.eventId, { userId: req.user.id });
|
||||
res.json(out);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/volunteers/roles/:id/sign-up
|
||||
const signUp = async (req, res) => {
|
||||
try {
|
||||
const { slot, role, event } = await v.signUp({ roleId: req.params.id, userId: req.user.id });
|
||||
// req.user has no notification preference; the message goes the way they like it.
|
||||
later(prisma.user.findUnique({ where: { id: req.user.id }, select: CONTACT }).then(user => v.signedUpMessages({ user, role, event })));
|
||||
res.status(201).json({ slotId: slot.id, ticket: !!slot.registrationId });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Withdraw from a shift (the event's notify recipients are told)
|
||||
// @route DELETE /api/volunteers/slots/:id
|
||||
const withdraw = async (req, res) => {
|
||||
try {
|
||||
const out = await v.leave({ slotId: req.params.id, userId: req.user.id });
|
||||
later(v.sendLeft(out));
|
||||
res.json({ message: 'You\'re off the roster for this shift.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc "Can't make it": ask the event's other volunteers to take over
|
||||
// @route POST /api/volunteers/slots/:id/swap { note? }
|
||||
const requestSwap = async (req, res) => {
|
||||
try {
|
||||
const out = await v.requestSwap({ slotId: req.params.id, userId: req.user.id, note: req.body?.note });
|
||||
if (out.created) later(v.sendSwapRequest(out));
|
||||
res.status(out.created ? 201 : 200).json({ swapId: out.swap.id });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/volunteers/swaps/:id
|
||||
const cancelSwap = async (req, res) => {
|
||||
try {
|
||||
res.json(await v.cancelSwap({ swapId: req.params.id, userId: req.user.id }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Take over someone's place. Happens at once; the event's notify recipients are told.
|
||||
// @route POST /api/volunteers/swaps/:id/accept
|
||||
const acceptSwap = async (req, res) => {
|
||||
try {
|
||||
const out = await v.acceptSwap({ swapId: req.params.id, userId: req.user.id });
|
||||
later(v.sendSwapAccepted(out));
|
||||
res.json({ slotId: out.slot.id, role: out.slot.role.name, event: out.event.title });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Check in by scanning the event's volunteer QR code
|
||||
// @route POST /api/volunteers/check-in { qr }
|
||||
const checkIn = async (req, res) => {
|
||||
try {
|
||||
res.json(await v.checkInWithQr({ qr: req.body?.qr, userId: req.user.id }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Staff: the roster on the day ─────────────────────────────────────────────
|
||||
|
||||
// @desc Events with serving roles (or upcoming, for supervisors setting them up)
|
||||
// @route GET /api/volunteers/manage/events?all=1
|
||||
const listEvents = async (req, res) => {
|
||||
try {
|
||||
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const base = req.query.all === '1' && ['admin', 'supervisor'].includes(req.user.role)
|
||||
? { endDate: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } }
|
||||
: { endDate: { gte: since }, servingRoles: { some: {} } };
|
||||
const events = await prisma.event.findMany({
|
||||
where: withScope(base, await listWhere(req)),
|
||||
select: { id: true, title: true, startDate: true, endDate: true, scheduleType: true, campusId: true, _count: { select: { servingRoles: true } } },
|
||||
orderBy: { startDate: 'asc' },
|
||||
take: 200,
|
||||
});
|
||||
res.json(events.map(e => ({ id: e.id, title: e.title, startDate: e.startDate, endDate: e.endDate, scheduleType: e.scheduleType, campusId: e.campusId, roles: e._count.servingRoles })));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The roster sheet: roles with their volunteers, contact details and check-ins
|
||||
// @route GET /api/volunteers/manage/events/:eventId?log=1
|
||||
const getRoster = async (req, res) => {
|
||||
try {
|
||||
const out = await v.roster(req.params.eventId, { staff: true, userId: req.user.id });
|
||||
if (req.query.log === '1') {
|
||||
logDataAccess(req, { action: 'volunteer_roster', eventId: out.event.id, records: out.roles.reduce((n, r) => n + r.volunteers.length, 0) });
|
||||
}
|
||||
res.json(out);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The check-in QR code volunteers scan on the day
|
||||
// @route GET /api/volunteers/manage/events/:eventId/qr
|
||||
const getQr = async (req, res) => {
|
||||
try {
|
||||
res.json(await v.checkInQr(req.params.eventId));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/volunteers/manage/slots/:id/check-in { undo? }
|
||||
const checkInManual = async (req, res) => {
|
||||
try {
|
||||
const out = await v.checkInManual({ slotId: req.params.id, staffUserId: req.user.id, undo: !!req.body?.undo });
|
||||
if (out.changed) audit(req, 'volunteer_checked_in_manual', 'volunteer_slot', out.slot.id, { volunteer: out.slot.user.name, role: out.slot.role.name, undo: !!req.body?.undo });
|
||||
res.json({ slotId: out.slot.id, changed: out.changed });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Supervisors: roles, people, settings, reports ────────────────────────────
|
||||
|
||||
// @route GET /api/volunteers/manage/events/:eventId/settings
|
||||
const getSettings = async (req, res) => {
|
||||
try {
|
||||
res.json(await v.getSettings(req.params.eventId));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Free volunteer place on/off: { optionId } ('new' adds a free "Volunteer" ticket, null = off)
|
||||
// @route PUT /api/volunteers/manage/events/:eventId/settings
|
||||
const saveSettings = async (req, res) => {
|
||||
try {
|
||||
const out = await v.saveSettings(req.params.eventId, { optionId: req.body?.optionId ?? null });
|
||||
audit(req, 'volunteer_settings_updated', 'event', out.event.id, { volunteerOptionId: out.event.volunteerOptionId });
|
||||
res.json(out);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/volunteers/manage/events/:eventId/roles { name, needed, sessionId?, startsAt?, endsAt?, description?, kidsRoomId? }
|
||||
const createRole = async (req, res) => {
|
||||
try {
|
||||
const role = await v.createRole(req.params.eventId, req.body);
|
||||
audit(req, 'volunteer_roles_changed', 'serving_role', role.id, { eventId: role.eventId, created: role.name, needed: role.needed });
|
||||
res.status(201).json(role);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route PUT /api/volunteers/manage/roles/:id
|
||||
const updateRole = async (req, res) => {
|
||||
try {
|
||||
const role = await v.updateRole(req.params.id, req.body);
|
||||
audit(req, 'volunteer_roles_changed', 'serving_role', role.id, { eventId: role.eventId, updated: role.name, needed: role.needed });
|
||||
res.json(role);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/volunteers/manage/roles/:id (its volunteers are told)
|
||||
const deleteRole = async (req, res) => {
|
||||
try {
|
||||
const out = await v.deleteRole(req.params.id);
|
||||
audit(req, 'volunteer_roles_changed', 'serving_role', out.role.id, { eventId: out.role.eventId, deleted: out.role.name, volunteers: out.volunteers.length });
|
||||
later(v.sendRoleDeleted(out));
|
||||
res.json({ message: 'Deleted', volunteers: out.volunteers.length });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Put someone on a role (allowed beyond the number needed); they're told
|
||||
// @route POST /api/volunteers/manage/roles/:id/assign { userId }
|
||||
const assign = async (req, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { id: String(req.body?.userId || '') }, select: CONTACT });
|
||||
if (!user) throw v.volunteerError('Choose who to put on the roster.', 404);
|
||||
const { slot, role, event } = await v.signUp({ roleId: req.params.id, userId: user.id, assignedById: req.user.id });
|
||||
audit(req, 'volunteer_assigned', 'volunteer_slot', slot.id, { eventId: event.id, role: role.name, volunteer: user.name });
|
||||
if (user.isActive) later(v.signedUpMessages({ user, role, event, by: req.user.name }));
|
||||
res.status(201).json({ slotId: slot.id, ticket: !!slot.registrationId });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Take someone off a role (not after they've checked in); they're told
|
||||
// @route DELETE /api/volunteers/manage/slots/:id
|
||||
const remove = async (req, res) => {
|
||||
try {
|
||||
const out = await v.leave({ slotId: req.params.id });
|
||||
audit(req, 'volunteer_removed', 'volunteer_slot', out.slot.id, { eventId: out.event.id, role: out.slot.role.name, volunteer: out.slot.user.name });
|
||||
later(v.sendLeft({ ...out, byStaff: req.user.name }));
|
||||
res.json({ message: 'Removed' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Hours served per person (checked-in shifts), over the events the user can see
|
||||
// @route GET /api/volunteers/manage/hours?eventId=&from=&to=
|
||||
const hours = async (req, res) => {
|
||||
try {
|
||||
const scope = await listWhere(req);
|
||||
const eventWhere = withScope(req.query.eventId ? { id: String(req.query.eventId) } : {}, scope);
|
||||
const rows = await v.hoursServed({ eventWhere, from: req.query.from || null, to: req.query.to || null });
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getMine, getEventRoles, signUp, withdraw, requestSwap, cancelSwap, acceptSwap, checkIn,
|
||||
listEvents, getRoster, getQr, checkInManual,
|
||||
getSettings, saveSettings, createRole, updateRole, deleteRole, assign, remove, hours,
|
||||
};
|
||||
@@ -230,6 +230,11 @@ async function handleIncomingMessage(req) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Proof for the POPIA readiness page that WAWP's webhook (and so STOP) is set up.
|
||||
const prisma = require('../config/db');
|
||||
const now = new Date().toISOString();
|
||||
prisma.appSetting.upsert({ where: { key: 'wawp_webhook_last_seen' }, update: { value: now }, create: { key: 'wawp_webhook_last_seen', value: now } }).catch(() => {});
|
||||
|
||||
const payload = req.body?.payload || {};
|
||||
if (payload.fromMe || !isStopKeyword(payload.body)) return;
|
||||
const phone = phoneFromChatId(payload.from);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 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_update_requested_at, hub_update_requested_version.
|
||||
* These keys are kept out of the generic settings endpoints (settingsController).
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
const { encrypt, decrypt } = require('../utils/encryption');
|
||||
const { verifyToken, clearLicenceCache } = require('./licence');
|
||||
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', 'hub_update_requested_at', 'hub_update_requested_version'];
|
||||
|
||||
function hubUrl() {
|
||||
return String(process.env.HUB_URL || 'https://events-hub.crosscode.co.za').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
class HubError extends Error {
|
||||
constructor(message, code, status) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.statusCode = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(values) {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === null) await prisma.appSetting.deleteMany({ where: { key } });
|
||||
else await prisma.appSetting.upsert({ where: { key }, update: { value: String(value) }, create: { key, value: String(value) } });
|
||||
}
|
||||
clearLicenceCache();
|
||||
}
|
||||
|
||||
async function read(keys) {
|
||||
const rows = await prisma.appSetting.findMany({ where: { key: { in: keys } } });
|
||||
return Object.fromEntries(rows.map(r => [r.key, r.value]));
|
||||
}
|
||||
|
||||
async function post(path, body, headers = {}) {
|
||||
const raw = JSON.stringify(body);
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${hubUrl()}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(typeof headers === 'function' ? headers(raw) : headers) },
|
||||
body: raw,
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
} catch (e) {
|
||||
throw new HubError(`The Hub (${hubUrl()}) could not be reached: ${e?.message || e}`, 'HUB_UNREACHABLE', 502);
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new HubError(data.message || `The Hub answered ${res.status}`, data.code || 'HUB_ERROR', res.status);
|
||||
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 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)) {
|
||||
throw new HubError('Set the site\'s web address first (FRONTEND_URL on the server, or Site settings → Organisation).', 'NO_SITE_URL', 400);
|
||||
}
|
||||
const data = await post('/api/installs/register', {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
licenceKey: String(licenceKey || '').trim(),
|
||||
install: { ...install, adminContact: adminContact ? { name: adminContact.name || null, email: adminContact.email || null } : null },
|
||||
});
|
||||
const claims = verifyToken(data.licence);
|
||||
if (!claims || claims.installId !== data.installId) {
|
||||
throw new HubError('The Hub answered with a licence this install can\'t verify. Is HUB_URL pointing at the right Hub?', 'BAD_LICENCE', 502);
|
||||
}
|
||||
await save({
|
||||
hub_install_id: data.installId,
|
||||
hub_install_secret: encrypt(data.installSecret),
|
||||
hub_licence: data.licence,
|
||||
hub_last_heartbeat_at: new Date().toISOString(),
|
||||
hub_last_error: null,
|
||||
hub_latest_version: data.latestVersion || null,
|
||||
});
|
||||
return claims;
|
||||
}
|
||||
|
||||
/** Send a heartbeat (the `hub-heartbeat` job). Skips quietly when not connected. */
|
||||
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 body = { protocolVersion: PROTOCOL_VERSION, version: report.APP_VERSION, health: await report.health(), stats: await report.stats() };
|
||||
try {
|
||||
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).
|
||||
...(claims && claims.installId === s.hub_install_id ? { hub_licence: data.licence } : {}),
|
||||
hub_last_heartbeat_at: new Date().toISOString(),
|
||||
hub_last_error: null,
|
||||
hub_latest_version: data.latestVersion || null,
|
||||
});
|
||||
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): 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Licence checks (docs/hub-protocol.md §4). The Hub signs a licence token with its private
|
||||
* key; this install checks it with the public key and works out its state, offline:
|
||||
*
|
||||
* active — everything works
|
||||
* grace — past the end date for up to graceDays: works, site admins see a banner
|
||||
* 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 — 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');
|
||||
const prisma = require('../config/db');
|
||||
const { hubPublicKey } = require('./publicKey');
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const READ_ONLY_STATES = new Set(['suspended', 'revoked', 'unlicensed']);
|
||||
const CACHE_MS = 30 * 1000;
|
||||
|
||||
let publicKeyObject = null;
|
||||
let publicKeyPem = null;
|
||||
function publicKey() {
|
||||
const pem = hubPublicKey();
|
||||
if (!pem) return null;
|
||||
if (pem !== publicKeyPem) {
|
||||
try {
|
||||
publicKeyObject = crypto.createPublicKey(pem);
|
||||
} catch {
|
||||
throw new Error("The Hub public key can't be read — check HUB_PUBLIC_KEY in backend/.env (copy it from the Hub's Settings page).");
|
||||
}
|
||||
publicKeyPem = pem;
|
||||
}
|
||||
return publicKeyObject;
|
||||
}
|
||||
|
||||
/** The token's claims if the Hub signed it, else null. */
|
||||
function verifyToken(token, key = publicKey()) {
|
||||
if (!key) return null;
|
||||
const parts = String(token || '').split('.');
|
||||
if (parts.length !== 3 || parts[0] !== 'hel1') return null;
|
||||
try {
|
||||
const ok = crypto.verify(null, Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(parts[2], 'base64url'));
|
||||
return ok ? JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The state for verified claims (or null = never connected). Pure, for tests.
|
||||
* Returns { state, readOnly, endsAt, graceEndsAt, daysLeft }.
|
||||
*/
|
||||
function stateFor(claims, installId, now = new Date()) {
|
||||
if (!claims || (installId && claims.installId !== installId)) {
|
||||
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;
|
||||
const graceEndsAt = endsAt ? new Date(endsAt.getTime() + (claims.graceDays || 0) * DAY) : null;
|
||||
let state;
|
||||
if (claims.status === 'revoked') state = 'revoked';
|
||||
else if (claims.status === 'suspended') state = 'suspended';
|
||||
else if (!endsAt || now < endsAt) state = 'active';
|
||||
else if (now < graceEndsAt) state = 'grace';
|
||||
else state = 'suspended';
|
||||
const daysLeft = state === 'grace' ? Math.max(0, Math.ceil((graceEndsAt - now) / DAY)) : null;
|
||||
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', '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 = {
|
||||
keyConfigured: !!publicKey(),
|
||||
connected: !!s.hub_install_id,
|
||||
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;
|
||||
}
|
||||
|
||||
function clearLicenceCache() {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
module.exports = { verifyToken, stateFor, licenceStatus, clearLicenceCache, READ_ONLY_STATES };
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 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`).
|
||||
*
|
||||
* 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 = "MCowBQYDK2VwAyEAcKpEo/JdS96wR3r9yh8uFUxBlzPUd2YKbC60fNRUhoU=";
|
||||
|
||||
/**
|
||||
* A public key as proper PEM, however it was pasted: with real line breaks, with "\n", all on
|
||||
* one line ("-----BEGIN PUBLIC KEY-----MCow…-----END PUBLIC KEY-----"), or just the base64.
|
||||
*/
|
||||
function normalisePem(input) {
|
||||
const text = String(input || '').replace(/\\n/g, '\n').trim();
|
||||
if (!text) return null;
|
||||
const body = text
|
||||
.replace(/-----BEGIN PUBLIC KEY-----/, '')
|
||||
.replace(/-----END PUBLIC KEY-----/, '')
|
||||
.replace(/\s+/g, '');
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(body)) return text; // not base64 — let the caller's parse fail clearly
|
||||
const lines = body.match(/.{1,64}/g).join('\n');
|
||||
return `-----BEGIN PUBLIC KEY-----\n${lines}\n-----END PUBLIC KEY-----\n`;
|
||||
}
|
||||
|
||||
function hubPublicKey() {
|
||||
return normalisePem(process.env.HUB_PUBLIC_KEY) || normalisePem(PRODUCTION_PUBLIC_KEY);
|
||||
}
|
||||
|
||||
module.exports = { hubPublicKey, normalisePem, PRODUCTION_PUBLIC_KEY };
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 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
|
||||
* arrive (they're mounted before this middleware).
|
||||
*/
|
||||
|
||||
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\/?$/],
|
||||
['POST', /^\/api\/payments\/?$/],
|
||||
['POST', /^\/api\/payments\/(checkout|yoco-checkout)(\/send)?\/?$/],
|
||||
['POST', /^\/api\/payments\/pay\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/waitlist\/?$/],
|
||||
['POST', /^\/api\/coupons\/?$/],
|
||||
['POST', /^\/api\/bundles\/event\/[^/]+\/?$/],
|
||||
// Volunteer rosters: new roles and new people on them (they can carry a free ticket).
|
||||
['POST', /^\/api\/volunteers\/roles\/[^/]+\/sign-up\/?$/],
|
||||
['POST', /^\/api\/volunteers\/manage\/events\/[^/]+\/roles\/?$/],
|
||||
['POST', /^\/api\/volunteers\/manage\/roles\/[^/]+\/assign\/?$/],
|
||||
];
|
||||
|
||||
function isBlocked(method, path) {
|
||||
return BLOCKED.some(([m, re]) => m === method && re.test(path));
|
||||
}
|
||||
|
||||
async function licenceReadOnly(req, res, next) {
|
||||
if (!isBlocked(req.method, req.path)) return next();
|
||||
try {
|
||||
const status = await licenceStatus();
|
||||
if (!status.readOnly) return 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 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.
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { licenceReadOnly, isBlocked, BLOCKED };
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* What this install tells the Hub (docs/hub-protocol.md §3): health, and aggregate counts —
|
||||
* no personal information. Income goes as a range, never an amount.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const prisma = require('../config/db');
|
||||
const errorCounter = require('../utils/errorCounter');
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const APP_VERSION = require('../../package.json').version;
|
||||
const STARTED_AT = Date.now();
|
||||
|
||||
const INCOME_BANDS = [
|
||||
[1000, 'R0–R1 000'],
|
||||
[5000, 'R1 001–R5 000'],
|
||||
[20000, 'R5 001–R20 000'],
|
||||
[50000, 'R20 001–R50 000'],
|
||||
[100000, 'R50 001–R100 000'],
|
||||
];
|
||||
|
||||
/** The range an amount falls in: "R5 001–R20 000", or "over R100 000". */
|
||||
function incomeBand(amount) {
|
||||
const rand = Math.max(0, Math.round(Number(amount) || 0));
|
||||
for (const [max, label] of INCOME_BANDS) if (rand <= max) return label;
|
||||
return 'over R100 000';
|
||||
}
|
||||
|
||||
async function postgresVersion() {
|
||||
try {
|
||||
const rows = await prisma.$queryRawUnsafe('SHOW server_version');
|
||||
return String(rows[0]?.server_version || '').split(' ')[0] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Migrations in the repo that the database hasn't applied. */
|
||||
async function pendingMigrations() {
|
||||
try {
|
||||
const dir = path.join(__dirname, '..', '..', 'prisma', 'migrations');
|
||||
const folders = fs.readdirSync(dir, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name);
|
||||
const rows = await prisma.$queryRawUnsafe('SELECT migration_name FROM "_prisma_migrations" WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL');
|
||||
const applied = new Set(rows.map(r => r.migration_name));
|
||||
return folders.filter(f => !applied.has(f)).length;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function disk() {
|
||||
try {
|
||||
const s = await fs.promises.statfs(path.join(__dirname, '..', '..'));
|
||||
return { freeBytes: s.bavail * s.bsize, totalBytes: s.blocks * s.bsize };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function jobsHealth() {
|
||||
try {
|
||||
const { jobStatus } = require('../jobs');
|
||||
const status = await jobStatus(prisma);
|
||||
const since = Date.now() - DAY;
|
||||
const failed = status.jobs.flatMap(j => j.recent).filter(r => r.state === 'failed' && new Date(r.completedOn || r.startedOn || 0).getTime() > since).length;
|
||||
return { mode: status.mode, failedLast24h: failed };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function lastBackupAt() {
|
||||
try {
|
||||
const backups = await require('../utils/backupUtils').listBackups();
|
||||
return backups[0]?.createdAt ? new Date(backups[0].createdAt).toISOString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function health() {
|
||||
let db = true;
|
||||
try { await prisma.$queryRawUnsafe('SELECT 1'); } catch { db = false; }
|
||||
const [jobs, backupAt, diskInfo, pending] = await Promise.all([jobsHealth(), lastBackupAt(), disk(), pendingMigrations()]);
|
||||
return {
|
||||
uptimeSeconds: Math.round((Date.now() - STARTED_AT) / 1000),
|
||||
db,
|
||||
jobs,
|
||||
lastBackupAt: backupAt,
|
||||
disk: diskInfo,
|
||||
errorsLast24h: errorCounter.countLast24h(),
|
||||
pendingMigrations: pending,
|
||||
};
|
||||
}
|
||||
|
||||
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([
|
||||
prisma.user.count({ where: realUser }),
|
||||
prisma.event.count({ where: { isActive: true, startDate: { lte: now }, endDate: { gte: now } } }),
|
||||
prisma.event.count({ where: { isActive: true, startDate: { gt: now } } }),
|
||||
prisma.event.count(),
|
||||
prisma.registration.count({ where: { createdAt: { gte: since } } }),
|
||||
prisma.registration.count(),
|
||||
prisma.ticket.count({ where: { createdAt: { gte: since } } }),
|
||||
prisma.ticket.count(),
|
||||
prisma.ticketUsage.count({ where: { scannedAt: { gte: since } } }),
|
||||
prisma.payment.aggregate({ where: { createdAt: { gte: since } }, _sum: { amount: true } }),
|
||||
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 },
|
||||
registrations: { last30d: regs30, total: regsTotal },
|
||||
tickets: { last30d: tickets30, total: ticketsTotal },
|
||||
checkIns: { last30d: checkIns30 },
|
||||
// Messages aren't logged one by one, so there's nothing to count yet.
|
||||
messages: null,
|
||||
paymentProviders: providers,
|
||||
campuses: campusesOn,
|
||||
campusCount,
|
||||
income: { last30d: incomeBand(income30._sum.amount), allTime: incomeBand(incomeAll._sum.amount) },
|
||||
};
|
||||
}
|
||||
|
||||
/** What the install says about itself when registering. */
|
||||
async function installInfo() {
|
||||
const { getSetting } = require('../utils/settingsCache');
|
||||
const [name, url, waConfigured, providers, campusesOn] = await Promise.all([
|
||||
getSetting('org_name', ''),
|
||||
getSetting('app_base_url', process.env.FRONTEND_URL || ''),
|
||||
require('../utils/whatsapp').isConfigured().catch(() => false),
|
||||
require('../payments').availableProviders().then(list => list.map(p => p.id)).catch(() => []),
|
||||
require('../utils/campuses').campusesEnabled().catch(() => false),
|
||||
]);
|
||||
return {
|
||||
name: name || 'Cross Events',
|
||||
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: campusesOn },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { APP_VERSION, incomeBand, health, stats, installInfo };
|
||||
+144
-4
@@ -156,7 +156,35 @@ app.use('/api/webhooks', webhookRoutes);
|
||||
// Apply JSON body parser for all non-webhook routes
|
||||
app.use(express.json());
|
||||
|
||||
// Count server errors (any 5xx response) for the Hub heartbeat.
|
||||
app.use((req, res, next) => {
|
||||
res.on('finish', () => { if (res.statusCode >= 500) require('./utils/errorCounter').recordError(); });
|
||||
next();
|
||||
});
|
||||
|
||||
// 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.
|
||||
app.get('/health', async (req, res) => {
|
||||
const report = require('./hub/report');
|
||||
let db = true;
|
||||
try { await prisma.$queryRaw`SELECT 1`; } catch { db = false; }
|
||||
const health = db ? await report.health().catch(() => null) : null;
|
||||
res.status(db ? 200 : 503).json({
|
||||
status: db ? 'ok' : 'error',
|
||||
version: report.APP_VERSION,
|
||||
db,
|
||||
jobs: health?.jobs || null,
|
||||
lastBackupAt: health?.lastBackupAt || null,
|
||||
});
|
||||
});
|
||||
|
||||
// Use routes
|
||||
app.use('/api/hub', require('./routes/hubRoutes'));
|
||||
app.use('/api/campuses', require('./routes/campusRoutes'));
|
||||
app.use('/api/kids', require('./routes/kidsRoutes'));
|
||||
app.use('/api/volunteers', require('./routes/volunteerRoutes'));
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
app.use('/api/registrations', registrationRoutes);
|
||||
@@ -492,13 +520,13 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[
|
||||
{ status:200, desc:'Success', body:{ exists:true, hasEmail:true, hasPhone:false }},
|
||||
]},
|
||||
{ method:'GET', path:'/api/users/:id', auth:'admin', desc:'Get a single user by ID',
|
||||
{ method:'GET', path:'/api/users/:id', auth:'admin', desc:'Get a single user by ID (with scopedCampuses)',
|
||||
pathParams:{ ':id':'User UUID' },
|
||||
responses:[
|
||||
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', isActive:true, phoneNumber:'+27821234567', notificationPreference:'email' }},
|
||||
{ status:404, desc:'Not found', body:{ message:'User not found' }},
|
||||
]},
|
||||
{ method:'PUT', path:'/api/users/:id', auth:'admin', desc:'Update any user (including role)',
|
||||
{ method:'PUT', path:'/api/users/:id', auth:'admin', desc:'Update any user (including role). scopedCampusIds limits a supervisor/staff member to those campuses (empty = every campus; audited)',
|
||||
pathParams:{ ':id':'User UUID' },
|
||||
request:{ body:{ role:'supervisor', isActive:true, name:'Jane Doe' }},
|
||||
responses:[
|
||||
@@ -886,6 +914,104 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:200, desc:'Success', body:[{ eventId:'ev-uuid-...', title:'Camp 2025', totalTickets:50, scanned:38, remaining:12 }]}]},
|
||||
]},
|
||||
|
||||
{ title: 'Hub and licence', base: '/api/hub', endpoints: [
|
||||
{ method:'GET', path:'/health', auth:'public', desc:'Status, version, database, background jobs and last backup (503 if the database is down)',
|
||||
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 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: 'Kids check-in', base: '/api/kids', endpoints: [
|
||||
{ method:'GET', path:'/api/kids/me', auth:'user', desc:'My family (children, authorised adults, parents), the family QR token, live pickup codes and pages from the last 24 hours' },
|
||||
{ method:'POST', path:'/api/kids/me', auth:'user', desc:'Start my family', request:{ body:{ name:'The Smith family' } } },
|
||||
{ method:'PUT', path:'/api/kids/me', auth:'user', desc:'Rename my family' },
|
||||
{ method:'DELETE', path:'/api/kids/me', auth:'user', desc:'Leave my family (the last parent leaving deletes it with its children)' },
|
||||
{ method:'POST', path:'/api/kids/me/children', auth:'user', desc:'Add a child (allergies, medical and custody notes are stored encrypted)',
|
||||
request:{ body:{ firstName:'Mia', lastName:'Smith', dateOfBirth:'2021-03-04', allergies:'Peanuts', medicalNotes:'', custodyNotes:'' } } },
|
||||
{ method:'PUT', path:'/api/kids/me/children/:id', auth:'user', desc:'Change a child (custody note changes are audited)' },
|
||||
{ method:'DELETE', path:'/api/kids/me/children/:id', auth:'user', desc:'Remove a child' },
|
||||
{ method:'POST', path:'/api/kids/me/adults', auth:'user', desc:'Add an authorised adult (no account needed; up to 10)', request:{ body:{ name:'Mary Smith', phone:'0821234567', relationship:'Gran' } } },
|
||||
{ method:'PUT', path:'/api/kids/me/adults/:id', auth:'user', desc:'Change an authorised adult' },
|
||||
{ method:'DELETE', path:'/api/kids/me/adults/:id', auth:'user', desc:'Remove an authorised adult' },
|
||||
{ method:'GET', path:'/api/kids/setup-events', auth:'supervisor+', desc:'Upcoming events to set kids check-in up for (campus-scoped)' },
|
||||
{ method:'GET', path:'/api/kids/events/:eventId/settings', auth:'supervisor+', desc:'Kids check-in settings, the main tickets that can be the kids ticket and the allocation sets' },
|
||||
{ method:'PUT', path:'/api/kids/events/:eventId/settings', auth:'supervisor+', desc:'Switch kids check-in on/off and choose the kids ticket and rooms ("new" makes a free "Kids check-in" ticket or a "Kids rooms" set). Audited as kids_settings_updated',
|
||||
request:{ body:{ enabled:true, optionId:'new', roomSetId:'new' } } },
|
||||
{ method:'GET', path:'/api/kids/events', auth:'staff+', desc:'Events with kids check-in from the last week on' },
|
||||
{ method:'GET', path:'/api/kids/events/:eventId/board', auth:'staff+', desc:'Rooms (counts, leaders on duty, full/over ratio), check-ins with allergies, medical and custody notes, and alerts for today\'s occasion. ?log=1 writes the access log' },
|
||||
{ method:'GET', path:'/api/kids/events/:eventId/families?q=', auth:'staff+', desc:'Find families by family, child or parent name, email or phone' },
|
||||
{ method:'POST', path:'/api/kids/events/:eventId/families', auth:'staff+', desc:'New family at the desk: the parent\'s account is matched by email/phone or created inactive',
|
||||
request:{ body:{ name:'The Smith family', parent:{ name:'Anna Smith', phone:'0821234567', email:'' }, children:[{ firstName:'Mia', dateOfBirth:'2021-03-04' }] } } },
|
||||
{ method:'GET', path:'/api/kids/events/:eventId/families/qr/:token', auth:'staff+', desc:'The family for a scanned family QR code' },
|
||||
{ method:'GET', path:'/api/kids/events/:eventId/families/:familyId', auth:'staff+', desc:'One family' },
|
||||
{ method:'POST', path:'/api/kids/events/:eventId/families/:familyId/parents', auth:'staff+', desc:'Add a parent to a family' },
|
||||
{ method:'POST', path:'/api/kids/events/:eventId/families/:familyId/children', auth:'staff+', desc:'Add a child (custody notes: supervisors only)' },
|
||||
{ method:'PUT', path:'/api/kids/events/:eventId/families/:familyId/children/:childId', auth:'staff+', desc:'Change a child' },
|
||||
{ method:'POST', path:'/api/kids/events/:eventId/families/:familyId/adults', auth:'staff+', desc:'Add an authorised adult' },
|
||||
{ method:'POST', path:'/api/kids/events/:eventId/check-in', auth:'staff+', desc:'Check children in: one pickup code per family per occasion, a free place on the kids ticket the first time, a ticket usage each time. Sends the code to the parents (and optionally an authorised adult)',
|
||||
request:{ body:{ familyId:'f_1', children:[{ childId:'c_1', roomId:'g_1' }], sendToAdultId:null } },
|
||||
responses:[{ status:201, desc:'Checked in', body:{ code:'K7P2', qr:'KIDS-K7P2', checkIns:[{ id:'k_1', name:'Mia Smith', room:{ id:'g_1', name:'Toddlers' }, sticker:'ALLERGY: PEANUTS' }] } }] },
|
||||
{ method:'GET', path:'/api/kids/events/:eventId/code/:code', auth:'staff+', desc:'The children on a pickup code (for confirming who is collected)' },
|
||||
{ method:'POST', path:'/api/kids/events/:eventId/check-out', auth:'staff+', desc:'Check out with the pickup code. 404 WRONG_CODE raises a room alert (audited as kids_wrong_code)',
|
||||
request:{ body:{ code:'K7P2', collectedBy:'Anna Smith', checkInIds:['k_1'] } } },
|
||||
{ method:'POST', path:'/api/kids/check-ins/:id/override', auth:'staff+', desc:'Check one child out without the code; reason and who collected required. Audited as kids_checkout_override' },
|
||||
{ method:'POST', path:'/api/kids/check-ins/:id/page', auth:'staff+', desc:'Ask the parents to come to the room (WhatsApp/email; shows on their Kids page)' },
|
||||
{ method:'PUT', path:'/api/kids/check-ins/:id/room', auth:'staff+', desc:'Move a child to another room' },
|
||||
{ method:'POST', path:'/api/kids/events/:eventId/duty', auth:'staff+', desc:'"I\'m on duty" in a room (on:false to go off), for the ratio' },
|
||||
{ method:'POST', path:'/api/kids/alerts/:id/resolve', auth:'staff+', desc:'Mark an alert as dealt with' },
|
||||
{ method:'GET', path:'/api/kids/events/:eventId/offline?deviceId=', auth:'staff+', desc:'Offline list for a desk: families, rooms, who is in, and 20 pickup codes reserved for the device' },
|
||||
{ method:'POST', path:'/api/kids/events/:eventId/sync', auth:'staff+', desc:'Sync an offline desk\'s check-ins and check-outs (idempotent by clientId). Audited as kids_offline_sync' },
|
||||
]},
|
||||
|
||||
{ title: 'Volunteer rosters', base: '/api/volunteers', endpoints: [
|
||||
{ method:'GET', path:'/api/volunteers/me', auth:'user', desc:'My shifts (upcoming and the last week), cover requests I could take, and events looking for volunteers' },
|
||||
{ method:'GET', path:'/api/volunteers/events/:eventId', auth:'user', desc:'An event\'s serving roles: shift, how many are needed and signed up, whether I\'m in it, open cover requests' },
|
||||
{ method:'POST', path:'/api/volunteers/roles/:id/sign-up', auth:'user', desc:'Sign up for a role. 409 ROLE_FULL when it\'s full, 409 SHIFT_CLASH when I serve elsewhere at the same time. Gives a free volunteer place when the event has one',
|
||||
responses:[{ status:201, desc:'Signed up', body:{ slotId:'s_1', ticket:true } }] },
|
||||
{ method:'DELETE', path:'/api/volunteers/slots/:id', auth:'user', desc:'Withdraw from a shift (not after checking in or once it\'s over). The event\'s notify recipients are emailed' },
|
||||
{ method:'POST', path:'/api/volunteers/slots/:id/swap', auth:'user', desc:'"Can\'t make it": ask for cover. The event\'s other volunteers are told; the request shows on everyone\'s Serving page', request:{ body:{ note:'Away that weekend' } } },
|
||||
{ method:'DELETE', path:'/api/volunteers/swaps/:id', auth:'user', desc:'Cancel my cover request' },
|
||||
{ method:'POST', path:'/api/volunteers/swaps/:id/accept', auth:'user', desc:'Take over someone\'s place. Happens at once (409 SWAP_CLOSED if someone was first); both volunteers and the event\'s notify recipients are told' },
|
||||
{ method:'POST', path:'/api/volunteers/check-in', auth:'user', desc:'Check in by scanning the event\'s volunteer QR code: every shift of mine on today is checked in; kids-room roles put me on duty in that room',
|
||||
request:{ body:{ qr:'SERVE-3f2a…' } }, responses:[{ status:200, desc:'Checked in', body:{ event:{ id:'e_1', title:'Sunday service' }, checkedIn:[{ role:'Kids', kidsRoom:'Toddlers' }], already:[] } }] },
|
||||
{ method:'GET', path:'/api/volunteers/manage/events', auth:'staff+', desc:'Events with serving roles from the last 30 days on (?all=1, supervisors: every upcoming event). Campus-scoped' },
|
||||
{ method:'GET', path:'/api/volunteers/manage/events/:eventId', auth:'staff+', desc:'The roster sheet: roles with volunteers, contact details, check-ins and fill rate. ?log=1 writes the access log' },
|
||||
{ method:'GET', path:'/api/volunteers/manage/events/:eventId/qr', auth:'staff+', desc:'The volunteer check-in QR code value for the event (made the first time)' },
|
||||
{ method:'POST', path:'/api/volunteers/manage/slots/:id/check-in', auth:'staff+', desc:'Check a volunteer in by hand (undo:true to undo). Audited as volunteer_checked_in_manual' },
|
||||
{ method:'GET', path:'/api/volunteers/manage/events/:eventId/settings', auth:'supervisor+', desc:'The volunteer ticket setting, the main tickets that can be it and the event\'s kids rooms' },
|
||||
{ method:'PUT', path:'/api/volunteers/manage/events/:eventId/settings', auth:'supervisor+', desc:'Free volunteer place: optionId of a main ticket, "new" for a free "Volunteer" ticket, null for off (the default). Audited as volunteer_settings_updated', request:{ body:{ optionId:'new' } } },
|
||||
{ method:'POST', path:'/api/volunteers/manage/events/:eventId/roles', auth:'supervisor+', desc:'Add a serving role (sessionId required on multi-session events; shift times optional). Audited as volunteer_roles_changed',
|
||||
request:{ body:{ name:'Welcome', needed:4, sessionId:null, startsAt:'2026-11-08T07:30:00.000Z', endsAt:'2026-11-08T09:30:00.000Z', description:'Front doors', kidsRoomId:null } } },
|
||||
{ method:'PUT', path:'/api/volunteers/manage/roles/:id', auth:'supervisor+', desc:'Change a role' },
|
||||
{ method:'DELETE', path:'/api/volunteers/manage/roles/:id', auth:'supervisor+', desc:'Delete a role (refused once someone checked in for it); its volunteers are told' },
|
||||
{ method:'POST', path:'/api/volunteers/manage/roles/:id/assign', auth:'supervisor+', desc:'Put someone on a role (allowed beyond the number needed); they\'re told. Audited as volunteer_assigned', request:{ body:{ userId:'u_1' } } },
|
||||
{ method:'DELETE', path:'/api/volunteers/manage/slots/:id', auth:'supervisor+', desc:'Take someone off a role (not after check-in); they\'re told. Audited as volunteer_removed' },
|
||||
{ method:'GET', path:'/api/volunteers/manage/hours?eventId=&from=&to=', auth:'supervisor+', desc:'Hours served per person (checked-in shifts × shift length), most first. Campus-scoped' },
|
||||
]},
|
||||
|
||||
{ title: 'Privacy (POPIA)', base: '/api/privacy', endpoints: [
|
||||
{ method:'GET', path:'/api/privacy/me', auth:'user', desc:'My news opt-in, its history, and my privacy requests',
|
||||
responses:[{ status:200, desc:'Success', body:{ news:{ email:true, whatsapp:false }, newsAskedAt:'2026-10-23T08:00:00.000Z', canWhatsApp:false, notificationPreference:'email', history:[{ channel:'email', granted:true, source:'signup', createdAt:'2026-10-23T08:00:00.000Z' }], requests:[] }}]},
|
||||
@@ -932,7 +1058,18 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:201, desc:'Created' }]},
|
||||
{ method:'GET', path:'/api/privacy/consent-log', auth:'admin', desc:'Consent records: ?type=news|notice|parental, ?q= name/email, ?page=',
|
||||
responses:[{ status:200, desc:'Success', body:{ total:1, pageSize:50, rows:[{ at:'2026-10-23T08:00:00.000Z', name:'Jane Doe', email:'jane@example.com', channel:'email', granted:false, source:'unsubscribe_link' }] }}]},
|
||||
{ method:'GET', path:'/api/privacy/access-log', auth:'admin', desc:'Bulk exports/views of personal information: ?action=, ?actorId=, ?from=, ?to=, ?page=',
|
||||
{ method:'GET', path:'/api/privacy/readiness', auth:'admin', desc:'POPIA readiness checklist with a score; each open item has a link to fix it',
|
||||
responses:[{ status:200, desc:'Success', body:{ score:77, passed:10, total:13, checks:[{ key:'notice', label:'Privacy notice published', ok:true, detail:'2 versions published', href:'/dashboard/admin/privacy?tab=notice', applies:true }] }}]},
|
||||
{ method:'GET', path:'/api/privacy/retention', auth:'admin', desc:'Retention policies, what the next run would affect, and the last run',
|
||||
responses:[{ status:200, desc:'Success', body:{ policies:{ inactive_accounts:{ enabled:true, months:60 } }, preview:{ inactive_accounts:{ toWarn:3, toAnonymise:0 } }, reviewedAt:null }}]},
|
||||
{ method:'PUT', path:'/api/privacy/retention', auth:'admin', desc:'Save the retention policies (all five)',
|
||||
request:{ body:{ policies:{ inactive_accounts:{ enabled:true, months:60 }, form_answers:{ enabled:true, months:12 }, login_history:{ enabled:true, months:24 }, payment_details:{ enabled:true, months:24 }, audit_logs:{ enabled:true, months:60 } } } },
|
||||
responses:[{ status:200, desc:'Saved' }]},
|
||||
{ method:'POST', path:'/api/privacy/retention/run', auth:'admin', desc:'Run the retention job now (warn/anonymise inactive accounts, delete expired data)',
|
||||
responses:[{ status:200, desc:'Done', body:{ result:{ inactive_accounts:{ warned:3, anonymised:0, waiting:0 }, login_history:{ deleted:120 } } }}]},
|
||||
{ method:'GET', path:'/api/privacy/breaches', auth:'admin', desc:'The breach register (security compromises); POST to record one, PUT /:id to update or mark the Regulator/people told',
|
||||
responses:[{ status:200, desc:'Success', body:[{ id:'br-uuid-...', title:'Phone with the attendee list lost', status:'open', discoveredAt:'2026-10-23T08:00:00.000Z', regulatorNotifiedAt:null }] }]},
|
||||
{ method:'GET', path:'/api/privacy/access-log', auth:'admin', desc:'Bulk exports/views of personal information: ?action=, ?actorId=, ?sensitive=true, ?from=, ?to=, ?page=',
|
||||
responses:[{ status:200, desc:'Success', body:{ total:1, pageSize:50, rows:[{ at:'2026-10-23T08:00:00.000Z', actor:'Sam Supervisor', action:'report_pdf', what:'Camp 2026 attendees', records:48 }] }}]},
|
||||
]},
|
||||
|
||||
@@ -1121,7 +1258,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' },
|
||||
@@ -1522,6 +1659,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';
|
||||
|
||||
@@ -45,6 +45,10 @@ const JOBS = [
|
||||
return { reminders, certificates };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'volunteers', label: 'Volunteer reminders the day before a shift', cron: '*/15 * * * *', env: 'VOLUNTEER_JOBS_ENABLED',
|
||||
run: () => require('../utils/volunteers').runReminderJob(),
|
||||
},
|
||||
{
|
||||
name: 'consent', label: 'Parental consent reminders and medical-detail deletion', cron: '0 9 * * *', env: 'CONSENT_JOBS_ENABLED',
|
||||
run: async () => {
|
||||
@@ -56,6 +60,14 @@ const JOBS = [
|
||||
name: 'privacy', label: 'Privacy requests: remind admins of requests due soon', cron: '0 8 * * *', env: 'PRIVACY_JOBS_ENABLED',
|
||||
run: () => require('../utils/privacy').remindDueRequests(new Date()),
|
||||
},
|
||||
{
|
||||
name: 'hub-heartbeat', label: 'Cross Events Hub: heartbeat and licence refresh', cron: '*/15 * * * *', env: 'HUB_HEARTBEAT_ENABLED',
|
||||
run: () => require('../hub/client').heartbeat(),
|
||||
},
|
||||
{
|
||||
name: 'retention', label: 'Retention: delete or anonymise personal information past its retention period', cron: '30 3 * * *', env: 'RETENTION_JOBS_ENABLED',
|
||||
run: () => require('../utils/retention').runRetention(new Date()),
|
||||
},
|
||||
{
|
||||
name: 'analytics-rollup', label: 'Funnel analytics: roll up visits older than 90 days', cron: '15 4 * * *', env: 'ANALYTICS_ROLLUP_ENABLED',
|
||||
run: async () => ({ rolledUp: await require('../utils/analytics').rollupOldEvents() }),
|
||||
@@ -239,8 +251,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
|
||||
|
||||
@@ -5,18 +5,19 @@ const {
|
||||
assign, autoAssign, clearAssignments, getSetLists, getMyGroups,
|
||||
} = require('../controllers/allocationController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, campusIndependent } = require('../utils/campusScope');
|
||||
|
||||
router.get('/my-groups', protect, staff, getMyGroups);
|
||||
router.get('/event/:eventId', protect, supervisor, getBoard);
|
||||
router.post('/sets', protect, supervisor, createSet);
|
||||
router.put('/sets/:id', protect, supervisor, updateSet);
|
||||
router.delete('/sets/:id', protect, supervisor, deleteSet);
|
||||
router.get('/sets/:id/lists', protect, supervisor, getSetLists);
|
||||
router.post('/sets/:id/groups', protect, supervisor, createGroup);
|
||||
router.put('/sets/:id/assignments', protect, supervisor, assign);
|
||||
router.delete('/sets/:id/assignments', protect, supervisor, clearAssignments);
|
||||
router.post('/sets/:id/auto', protect, supervisor, autoAssign);
|
||||
router.put('/groups/:id', protect, supervisor, updateGroup);
|
||||
router.delete('/groups/:id', protect, supervisor, deleteGroup);
|
||||
router.get('/my-groups', protect, staff, campusIndependent('only the groups the signed-in person leads'), getMyGroups);
|
||||
router.get('/event/:eventId', protect, supervisor, scopeEvent(), getBoard);
|
||||
router.post('/sets', protect, supervisor, scopeEvent({ from: req => req.body?.eventId }), createSet);
|
||||
router.put('/sets/:id', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), updateSet);
|
||||
router.delete('/sets/:id', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), deleteSet);
|
||||
router.get('/sets/:id/lists', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), getSetLists);
|
||||
router.post('/sets/:id/groups', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), createGroup);
|
||||
router.put('/sets/:id/assignments', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), assign);
|
||||
router.delete('/sets/:id/assignments', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), clearAssignments);
|
||||
router.post('/sets/:id/auto', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), autoAssign);
|
||||
router.put('/groups/:id', protect, supervisor, scopeEvent({ resolve: 'allocationGroup' }), updateGroup);
|
||||
router.delete('/groups/:id', protect, supervisor, scopeEvent({ resolve: 'allocationGroup' }), deleteGroup);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -3,6 +3,7 @@ const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const { trackStage, getFunnel } = require('../controllers/analyticsController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeList } = require('../utils/campusScope');
|
||||
|
||||
const trackLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
@@ -13,6 +14,6 @@ const trackLimiter = rateLimit({
|
||||
});
|
||||
|
||||
router.post('/track', trackLimiter, trackStage);
|
||||
router.get('/funnel', protect, supervisor, getFunnel);
|
||||
router.get('/funnel', protect, supervisor, scopeList, getFunnel);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -6,20 +6,21 @@ const {
|
||||
myCertificates, downloadCertificate, verifyCertificate,
|
||||
} = require('../controllers/attendanceController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, campusIndependent } = require('../utils/campusScope');
|
||||
|
||||
// Certificates — fixed paths first so they aren't taken as an :eventId
|
||||
router.get('/certificates/verify/:code', verifyCertificate);
|
||||
router.get('/certificates/mine', protect, myCertificates);
|
||||
router.post('/certificates/preview', protect, supervisor, previewCertificate);
|
||||
router.post('/certificates/preview', protect, supervisor, campusIndependent('previews a certificate template from the request body'), previewCertificate);
|
||||
router.get('/certificates/:id/pdf', protect, downloadCertificate);
|
||||
router.post('/certificates/:id/revoke', protect, supervisor, revokeCertificate);
|
||||
router.post('/certificates/:id/revoke', protect, supervisor, scopeEvent({ resolve: 'certificate' }), revokeCertificate);
|
||||
|
||||
// Attendance register
|
||||
router.get('/:eventId', protect, staff, getRegister);
|
||||
router.get('/:eventId/export', protect, staff, exportRegister);
|
||||
router.post('/:eventId/mark', protect, supervisor, markAttendance);
|
||||
router.post('/:eventId/certificates', protect, supervisor, issueCertificates);
|
||||
router.post('/:eventId/certificates/send', protect, supervisor, sendCertificatesHandler);
|
||||
router.get('/:eventId/certificates.pdf', protect, supervisor, downloadEventCertificates);
|
||||
router.get('/:eventId', protect, staff, scopeEvent(), getRegister);
|
||||
router.get('/:eventId/export', protect, staff, scopeEvent(), exportRegister);
|
||||
router.post('/:eventId/mark', protect, supervisor, scopeEvent(), markAttendance);
|
||||
router.post('/:eventId/certificates', protect, supervisor, scopeEvent(), issueCertificates);
|
||||
router.post('/:eventId/certificates/send', protect, supervisor, scopeEvent(), sendCertificatesHandler);
|
||||
router.get('/:eventId/certificates.pdf', protect, supervisor, scopeEvent(), downloadEventCertificates);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,8 +2,9 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { scheduleAutomations } = require('../controllers/automationController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
// Schedule multiple automation emails for an event
|
||||
router.post('/schedule', protect, supervisor, scheduleAutomations);
|
||||
router.post('/schedule', protect, supervisor, scopeEvent({ from: req => req.body?.eventId }), scheduleAutomations);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,8 +2,9 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getBanner, setBanner } = require('../controllers/bannerController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { unscopedOnly } = require('../utils/campusScope');
|
||||
|
||||
router.get('/', getBanner);
|
||||
router.post('/', protect, supervisor, setBanner);
|
||||
router.post('/', protect, supervisor, unscopedOnly, setBanner);
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,10 +2,11 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { previewBroadcast, sendBroadcast, scheduleBroadcast } = require('../controllers/broadcastController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeBroadcast } = require('../utils/campusScope');
|
||||
|
||||
// All routes require supervisor (or admin via middleware logic) access
|
||||
router.post('/preview', protect, supervisor, previewBroadcast);
|
||||
router.post('/send', protect, supervisor, sendBroadcast);
|
||||
router.post('/schedule', protect, supervisor, scheduleBroadcast);
|
||||
router.post('/preview', protect, supervisor, scopeBroadcast({ freeformField: 'emails' }), previewBroadcast);
|
||||
router.post('/send', protect, supervisor, scopeBroadcast({ freeformField: 'emails' }), sendBroadcast);
|
||||
router.post('/schedule', protect, supervisor, scopeBroadcast({ freeformField: 'emails' }), scheduleBroadcast);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,13 +2,14 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles } = require('../controllers/bundleController');
|
||||
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
// What a basket gets off before registering (guests included)
|
||||
router.post('/quote', optionalAuth, quoteBundles);
|
||||
|
||||
router.get('/event/:eventId', protect, supervisor, listBundles);
|
||||
router.post('/event/:eventId', protect, supervisor, createBundle);
|
||||
router.put('/:id', protect, supervisor, updateBundle);
|
||||
router.delete('/:id', protect, supervisor, deleteBundle);
|
||||
router.get('/event/:eventId', protect, supervisor, scopeEvent(), listBundles);
|
||||
router.post('/event/:eventId', protect, supervisor, scopeEvent({ need: 'manage' }), createBundle);
|
||||
router.put('/:id', protect, supervisor, scopeEvent({ resolve: 'bundle', need: 'manage' }), updateBundle);
|
||||
router.delete('/:id', protect, supervisor, scopeEvent({ resolve: 'bundle', need: 'manage' }), deleteBundle);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
getCampuses, getCampusBySlug, getCampusesAdmin, updateCampusSettings, createCampus, updateCampus, reorderCampuses, deleteCampus,
|
||||
getCampusAudience,
|
||||
} = require('../controllers/campusController');
|
||||
const { protect, admin, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Multi-campus (utils/campuses.js). The public list is empty while campuses are off.
|
||||
router.get('/', getCampuses);
|
||||
router.get('/by-slug/:slug', getCampusBySlug);
|
||||
router.get('/admin', protect, admin, getCampusesAdmin);
|
||||
router.put('/settings', protect, admin, updateCampusSettings);
|
||||
router.put('/order', protect, admin, reorderCampuses);
|
||||
router.post('/', protect, admin, createCampus);
|
||||
router.put('/:id', protect, admin, updateCampus);
|
||||
router.delete('/:id', protect, admin, deleteCampus);
|
||||
router.get('/:id/audience', protect, supervisor, scopeList, getCampusAudience);
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,11 +2,12 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventCashup, getCashByRecordedUser, savePersonCash, saveEventCashupDraft, closeEvent, reopenEvent, resolvePending, getCashupAudit } = require('../controllers/cashupController');
|
||||
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
router.get('/audit', protect, supervisor, getCashupAudit);
|
||||
router.get('/event/:eventId', protect, supervisor, getEventCashup);
|
||||
router.get('/event/:eventId/cash-by-user', protect, supervisor, getCashByRecordedUser);
|
||||
router.put('/event/:eventId/person-cash/:userId', protect, supervisor, savePersonCash);
|
||||
router.get('/audit', protect, supervisor, scopeList, getCashupAudit);
|
||||
router.get('/event/:eventId', protect, supervisor, scopeEvent({ need: 'manage' }), getEventCashup);
|
||||
router.get('/event/:eventId/cash-by-user', protect, supervisor, scopeEvent({ need: 'manage' }), getCashByRecordedUser);
|
||||
router.put('/event/:eventId/person-cash/:userId', protect, supervisor, scopeEvent({ need: 'manage' }), savePersonCash);
|
||||
router.put('/event/:eventId/draft', protect, admin, saveEventCashupDraft);
|
||||
router.post('/event/:eventId/close', protect, admin, closeEvent);
|
||||
router.post('/event/:eventId/reopen', protect, admin, reopenEvent);
|
||||
|
||||
@@ -3,15 +3,16 @@ const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const { getConsent, answerConsent, listEventConsent, resendRequest, resendPending, rerequestConsent, medicalList } = require('../controllers/consentController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
// The token in the link is the parent's only credential, so keep guessing slow.
|
||||
const consentLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
|
||||
|
||||
router.get('/events/:eventId', protect, staff, listEventConsent);
|
||||
router.post('/events/:eventId/resend-pending', protect, supervisor, resendPending);
|
||||
router.post('/events/:eventId/rerequest', protect, supervisor, rerequestConsent);
|
||||
router.get('/events/:eventId/medical', protect, supervisor, medicalList);
|
||||
router.post('/requests/:id/resend', protect, resendRequest);
|
||||
router.get('/events/:eventId', protect, staff, scopeEvent(), listEventConsent);
|
||||
router.post('/events/:eventId/resend-pending', protect, supervisor, scopeEvent(), resendPending);
|
||||
router.post('/events/:eventId/rerequest', protect, supervisor, scopeEvent(), rerequestConsent);
|
||||
router.get('/events/:eventId/medical', protect, supervisor, scopeEvent(), medicalList);
|
||||
router.post('/requests/:id/resend', protect, scopeEvent({ resolve: 'consentRequest', ownerOk: true }), resendRequest);
|
||||
|
||||
// Public consent page
|
||||
router.get('/:token', consentLimiter, getConsent);
|
||||
|
||||
@@ -2,9 +2,10 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventCosts, createEventCost, updateEventCost, deleteEventCost } = require('../controllers/costController');
|
||||
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
// Mounted at /api — paths below match the documented /api/events/:eventId/costs and /api/costs/:id shape
|
||||
router.get('/events/:eventId/costs', protect, supervisor, getEventCosts);
|
||||
router.get('/events/:eventId/costs', protect, supervisor, scopeEvent({ need: 'manage' }), getEventCosts);
|
||||
router.post('/events/:eventId/costs', protect, admin, createEventCost);
|
||||
router.put('/costs/:id', protect, admin, updateEventCost);
|
||||
router.delete('/costs/:id', protect, admin, deleteEventCost);
|
||||
|
||||
@@ -2,14 +2,15 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions } = require('../controllers/couponController');
|
||||
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Check a code against a basket before registering (guests included)
|
||||
router.post('/validate', optionalAuth, validateCode);
|
||||
router.post('/validate', optionalAuth, scopeEvent({ from: req => req.body?.eventId }), validateCode);
|
||||
|
||||
router.get('/', protect, supervisor, listCoupons);
|
||||
router.post('/', protect, supervisor, createCoupon);
|
||||
router.get('/redemptions', protect, supervisor, listRedemptions);
|
||||
router.get('/:id', protect, supervisor, getCoupon);
|
||||
router.put('/:id', protect, supervisor, updateCoupon);
|
||||
router.get('/', protect, supervisor, scopeList, listCoupons);
|
||||
router.post('/', protect, supervisor, scopeList, createCoupon);
|
||||
router.get('/redemptions', protect, supervisor, scopeList, listRedemptions);
|
||||
router.get('/:id', protect, supervisor, scopeList, getCoupon);
|
||||
router.put('/:id', protect, supervisor, scopeList, updateCoupon);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -29,6 +29,7 @@ const {
|
||||
getEventByAlias
|
||||
} = require('../controllers/eventController');
|
||||
const { protect, admin, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Public routes
|
||||
router.get('/', getEvents);
|
||||
@@ -49,40 +50,40 @@ router.get('/:id/ics', optionalAuth, getEventIcs);
|
||||
router.get('/by-alias/:redirectUrl', getEventByAlias);
|
||||
|
||||
// Create/Update/Delete event
|
||||
router.post('/', protect, supervisor, createEvent);
|
||||
router.post('/', protect, supervisor, scopeList, createEvent);
|
||||
router.route('/:id')
|
||||
.put(protect, supervisor, updateEvent)
|
||||
.put(protect, supervisor, scopeEvent({ need: 'manage' }), updateEvent)
|
||||
.delete(protect, admin, deleteEvent);
|
||||
|
||||
// Notification recipients: who gets registration/payment/daily-summary emails for this event
|
||||
router.route('/:id/notify-recipients')
|
||||
.get(protect, supervisor, getEventNotifyRecipients)
|
||||
.put(protect, supervisor, updateEventNotifyRecipients);
|
||||
.get(protect, supervisor, scopeEvent(), getEventNotifyRecipients)
|
||||
.put(protect, supervisor, scopeEvent({ need: 'manage' }), updateEventNotifyRecipients);
|
||||
|
||||
// Attachments routes
|
||||
router.get('/:id/attachments', listEventAttachments); // public read (used by event detail page)
|
||||
router.post('/:id/attachments', protect, supervisor, ...uploadEventAttachment);
|
||||
router.delete('/:eventId/attachments/:attachmentId', protect, supervisor, deleteEventAttachment);
|
||||
router.post('/:id/attachments', protect, supervisor, scopeEvent({ need: 'manage' }), ...uploadEventAttachment);
|
||||
router.delete('/:eventId/attachments/:attachmentId', protect, supervisor, scopeEvent({ need: 'manage' }), deleteEventAttachment);
|
||||
|
||||
// Bulk email attendees
|
||||
router.post('/:id/email-attendees', protect, supervisor, emailEventAttendees);
|
||||
router.post('/:id/email-attendees', protect, supervisor, scopeEvent(), emailEventAttendees);
|
||||
// Schedule bulk email to attendees
|
||||
router.post('/:id/email-attendees/schedule', protect, supervisor, scheduleEmailEventAttendees);
|
||||
router.post('/:id/email-attendees/schedule', protect, supervisor, scopeEvent(), scheduleEmailEventAttendees);
|
||||
// Bulk WhatsApp message to attendees
|
||||
router.post('/:id/whatsapp-attendees', protect, supervisor, whatsappEventAttendees);
|
||||
router.post('/:id/whatsapp-attendees', protect, supervisor, scopeEvent(), whatsappEventAttendees);
|
||||
// Schedule bulk WhatsApp message to attendees
|
||||
router.post('/:id/whatsapp-attendees/schedule', protect, supervisor, scheduleWhatsappEventAttendees);
|
||||
router.post('/:id/whatsapp-attendees/schedule', protect, supervisor, scopeEvent(), scheduleWhatsappEventAttendees);
|
||||
|
||||
// Event options routes
|
||||
router.post('/:id/options', protect, supervisor, createEventOption);
|
||||
router.post('/:id/options', protect, supervisor, scopeEvent({ need: 'manage' }), createEventOption);
|
||||
router.route('/options/:id')
|
||||
.put(protect, supervisor, updateEventOption)
|
||||
.put(protect, supervisor, scopeEvent({ resolve: 'eventOption', need: 'manage' }), updateEventOption)
|
||||
.delete(protect, admin, deleteEventOption);
|
||||
|
||||
// Option variant routes
|
||||
router.post('/options/:id/variants', protect, supervisor, createOptionVariant);
|
||||
router.post('/options/:id/variants', protect, supervisor, scopeEvent({ resolve: 'eventOption', need: 'manage' }), createOptionVariant);
|
||||
router.route('/variants/:id')
|
||||
.put(protect, supervisor, updateOptionVariant)
|
||||
.put(protect, supervisor, scopeEvent({ resolve: 'optionVariant', need: 'manage' }), updateOptionVariant)
|
||||
.delete(protect, admin, deleteOptionVariant);
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,13 +2,14 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventFeedback, saveEventFeedback, deleteEventFeedback, sendNow, getPublicForm, submitPublicForm, getSummary } = require('../controllers/feedbackController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
router.get('/public/:token', getPublicForm);
|
||||
router.post('/public/:token', submitPublicForm);
|
||||
router.get('/summary', protect, supervisor, getSummary);
|
||||
router.get('/event/:eventId', protect, supervisor, getEventFeedback);
|
||||
router.put('/event/:eventId', protect, supervisor, saveEventFeedback);
|
||||
router.delete('/event/:eventId', protect, supervisor, deleteEventFeedback);
|
||||
router.post('/event/:eventId/send-now', protect, supervisor, sendNow);
|
||||
router.get('/summary', protect, supervisor, scopeList, getSummary);
|
||||
router.get('/event/:eventId', protect, supervisor, scopeEvent(), getEventFeedback);
|
||||
router.put('/event/:eventId', protect, supervisor, scopeEvent({ need: 'manage' }), saveEventFeedback);
|
||||
router.delete('/event/:eventId', protect, supervisor, scopeEvent({ need: 'manage' }), deleteEventFeedback);
|
||||
router.post('/event/:eventId/send-now', protect, supervisor, scopeEvent({ need: 'manage' }), sendNow);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,8 +2,9 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listFormResponses } = require('../controllers/formController');
|
||||
const { protect, staff } = require('../middleware/authMiddleware');
|
||||
const { scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Staff or higher can view submitted forms
|
||||
router.get('/responses', protect, staff, listFormResponses);
|
||||
router.get('/responses', protect, staff, scopeList, listFormResponses);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getStatus, connect, heartbeatNow, requestUpdate } = require('../controllers/hubController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
// 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;
|
||||
@@ -0,0 +1,47 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const c = require('../controllers/kidsController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// My account → Kids (any signed-in parent; only their own family)
|
||||
router.get('/me', protect, c.getMine);
|
||||
router.post('/me', protect, c.createMine);
|
||||
router.put('/me', protect, c.renameMine);
|
||||
router.delete('/me', protect, c.leaveMine);
|
||||
router.post('/me/children', protect, c.saveMyChild);
|
||||
router.put('/me/children/:id', protect, c.saveMyChild);
|
||||
router.delete('/me/children/:id', protect, c.deleteMyChild);
|
||||
router.post('/me/adults', protect, c.saveMyAdult);
|
||||
router.put('/me/adults/:id', protect, c.saveMyAdult);
|
||||
router.delete('/me/adults/:id', protect, c.deleteMyAdult);
|
||||
|
||||
// Setup (supervisors)
|
||||
router.get('/setup-events', protect, supervisor, scopeList, c.setupEvents);
|
||||
router.get('/events/:eventId/settings', protect, supervisor, scopeEvent(), c.getSettings);
|
||||
router.put('/events/:eventId/settings', protect, supervisor, scopeEvent({ need: 'manage' }), c.saveSettings);
|
||||
|
||||
// The desk and the rooms (staff). Families are church-wide; staff reach them through an event
|
||||
// they can work on.
|
||||
router.get('/events', protect, staff, scopeList, c.listEvents);
|
||||
router.get('/events/:eventId/board', protect, staff, scopeEvent(), c.getBoard);
|
||||
router.get('/events/:eventId/families', protect, staff, scopeEvent(), c.searchFamilies);
|
||||
router.post('/events/:eventId/families', protect, staff, scopeEvent(), c.createFamilyAtDesk);
|
||||
router.get('/events/:eventId/families/qr/:token', protect, staff, scopeEvent(), c.familyByQr);
|
||||
router.get('/events/:eventId/families/:familyId', protect, staff, scopeEvent(), c.getFamily);
|
||||
router.post('/events/:eventId/families/:familyId/parents', protect, staff, scopeEvent(), c.addParentAtDesk);
|
||||
router.post('/events/:eventId/families/:familyId/children', protect, staff, scopeEvent(), c.saveChildAtDesk);
|
||||
router.put('/events/:eventId/families/:familyId/children/:childId', protect, staff, scopeEvent(), c.saveChildAtDesk);
|
||||
router.post('/events/:eventId/families/:familyId/adults', protect, staff, scopeEvent(), c.addAdultAtDesk);
|
||||
router.post('/events/:eventId/check-in', protect, staff, scopeEvent(), c.checkIn);
|
||||
router.post('/events/:eventId/check-out', protect, staff, scopeEvent(), c.checkOut);
|
||||
router.get('/events/:eventId/code/:code', protect, staff, scopeEvent(), c.lookupCode);
|
||||
router.post('/events/:eventId/duty', protect, staff, scopeEvent(), c.setDuty);
|
||||
router.get('/events/:eventId/offline', protect, staff, scopeEvent(), c.offlineSnapshot);
|
||||
router.post('/events/:eventId/sync', protect, staff, scopeEvent(), c.syncOffline);
|
||||
router.post('/check-ins/:id/override', protect, staff, scopeEvent({ resolve: 'kidsCheckIn' }), c.overrideCheckOut);
|
||||
router.post('/check-ins/:id/page', protect, staff, scopeEvent({ resolve: 'kidsCheckIn' }), c.pageParent);
|
||||
router.put('/check-ins/:id/room', protect, staff, scopeEvent({ resolve: 'kidsCheckIn' }), c.moveRoom);
|
||||
router.post('/alerts/:id/resolve', protect, staff, scopeEvent({ resolve: 'kidsAlert' }), c.resolveAlert);
|
||||
|
||||
module.exports = router;
|
||||
@@ -17,32 +17,38 @@ const {
|
||||
} = require('../controllers/paymentController');
|
||||
const { listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider, listOpenCheckouts, checkCheckoutStatus } = require('../controllers/paymentProvidersController');
|
||||
const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeBody, scopeList, unscopedOnly } = require('../utils/campusScope');
|
||||
|
||||
// Campus-scoped staff: payments follow their registration's or event's campus. Door payments for
|
||||
// church-wide events are allowed; refunds of them aren't (need 'manage'); a payment or donation
|
||||
// not linked to any event is for admins and unscoped supervisors (orphan 'deny').
|
||||
const paymentTarget = scopeBody([['registrationId', 'registration'], ['eventId', 'event']], { orphan: 'deny' });
|
||||
|
||||
// Protected routes
|
||||
router.post('/', protect, supervisor, createPayment);
|
||||
router.post('/', protect, supervisor, paymentTarget, createPayment);
|
||||
router.get('/providers', listPaymentProviders);
|
||||
router.get('/pay/:id', getPayPage);
|
||||
router.post('/pay/:id', choosePayProvider);
|
||||
router.get('/checkouts/open', protect, supervisor, listOpenCheckouts);
|
||||
router.post('/checkouts/check', protect, supervisor, checkCheckoutStatus);
|
||||
router.get('/checkouts/open', protect, supervisor, unscopedOnly, listOpenCheckouts);
|
||||
router.post('/checkouts/check', protect, supervisor, unscopedOnly, checkCheckoutStatus);
|
||||
router.get('/providers/settings', protect, admin, getPaymentProviderSettings);
|
||||
router.put('/providers/settings', protect, admin, updatePaymentProviderSettings);
|
||||
router.post('/checkout', protect, createCheckout);
|
||||
router.post('/checkout/send', protect, supervisor, sendPaymentLink);
|
||||
router.post('/checkout/send', protect, supervisor, scopeBody([['registrationId', 'registration']]), sendPaymentLink);
|
||||
// Older names, kept for existing clients
|
||||
router.post('/yoco-checkout', protect, createCheckout);
|
||||
router.post('/yoco-checkout/send', protect, supervisor, sendPaymentLink);
|
||||
router.post('/yoco-checkout/send', protect, supervisor, scopeBody([['registrationId', 'registration']]), sendPaymentLink);
|
||||
router.get('/mypayments', protect, getUserPayments);
|
||||
router.post('/:id/send-receipt', protect, sendReceipt);
|
||||
router.get('/:id', protect, getPaymentById);
|
||||
router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
|
||||
router.post('/:id/send-receipt', protect, scopeEvent({ resolve: 'payment', ownerOk: true }), sendReceipt);
|
||||
router.get('/:id', protect, scopeEvent({ resolve: 'payment', ownerOk: true }), getPaymentById);
|
||||
router.get('/registration/:registrationId', protect, scopeEvent({ resolve: 'registration', from: req => req.params.registrationId, ownerOk: true }), getPaymentsByRegistration);
|
||||
|
||||
// Admin/Staff routes
|
||||
router.get('/', protect, supervisor, getPayments);
|
||||
router.get('/event/:eventId', protect, staff, getPaymentsByEvent);
|
||||
router.put('/assign-donation', protect, supervisor, assignDonationToRegistration);
|
||||
router.post('/unassign-donation', protect, supervisor, unassignDonationFromRegistration);
|
||||
router.post('/refund', protect, supervisor, createRefund);
|
||||
router.get('/', protect, supervisor, scopeList, getPayments);
|
||||
router.get('/event/:eventId', protect, staff, scopeEvent(), getPaymentsByEvent);
|
||||
router.put('/assign-donation', protect, supervisor, scopeBody([['paymentId', 'payment']], { orphan: 'deny' }), scopeBody([['registrationId', 'registration']]), assignDonationToRegistration);
|
||||
router.post('/unassign-donation', protect, supervisor, scopeBody([['legId', 'payment']], { orphan: 'deny' }), unassignDonationFromRegistration);
|
||||
router.post('/refund', protect, supervisor, scopeBody([['paymentId', 'payment'], ['registrationId', 'registration']], { need: 'manage', orphan: 'deny' }), createRefund);
|
||||
router.get('/admin/stats', protect, admin, getPaymentStats);
|
||||
|
||||
module.exports = router;
|
||||
@@ -27,6 +27,13 @@ const {
|
||||
deleteOperator,
|
||||
consentLog,
|
||||
accessLog,
|
||||
getReadiness,
|
||||
getRetention,
|
||||
saveRetention,
|
||||
runRetentionNow,
|
||||
listBreaches,
|
||||
createBreach,
|
||||
updateBreach,
|
||||
} = require('../controllers/privacyAdminController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
@@ -66,4 +73,13 @@ router.delete('/operators/:id', protect, admin, deleteOperator);
|
||||
router.get('/consent-log', protect, admin, consentLog);
|
||||
router.get('/access-log', protect, admin, accessLog);
|
||||
|
||||
// Admin: readiness, retention, breach register
|
||||
router.get('/readiness', protect, admin, getReadiness);
|
||||
router.get('/retention', protect, admin, getRetention);
|
||||
router.put('/retention', protect, admin, saveRetention);
|
||||
router.post('/retention/run', protect, admin, runRetentionNow);
|
||||
router.get('/breaches', protect, admin, listBreaches);
|
||||
router.post('/breaches', protect, admin, createBreach);
|
||||
router.put('/breaches/:id', protect, admin, updateBreach);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,19 +2,20 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getAllProviderTransactions, getUnreconciledProviderTransactions, reconcileProviderTransaction, ignoreProviderTransaction } = require('../controllers/providerTransactionsController');
|
||||
const { protect, supervisor} = require('../middleware/authMiddleware');
|
||||
const { unscopedOnly } = require('../utils/campusScope');
|
||||
|
||||
// Mounted at /api/provider-transactions (and /api/yoco-transactions, the older name).
|
||||
|
||||
// GET all payment provider transactions (admin/supervisor)
|
||||
router.get('/', protect, supervisor, getAllProviderTransactions);
|
||||
router.get('/', protect, supervisor, unscopedOnly, getAllProviderTransactions);
|
||||
|
||||
// GET all unreconciled transactions (admin/supervisor)
|
||||
router.get('/unreconciled', protect, supervisor, getUnreconciledProviderTransactions);
|
||||
router.get('/unreconciled', protect, supervisor, unscopedOnly, getUnreconciledProviderTransactions);
|
||||
|
||||
// POST reconcile a transaction
|
||||
router.post('/:id/reconcile', protect, supervisor, reconcileProviderTransaction);
|
||||
router.post('/:id/reconcile', protect, supervisor, unscopedOnly, reconcileProviderTransaction);
|
||||
|
||||
// POST ignore a transaction
|
||||
router.post('/:id/ignore', protect, supervisor, ignoreProviderTransaction);
|
||||
router.post('/:id/ignore', protect, supervisor, unscopedOnly, ignoreProviderTransaction);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -17,29 +17,33 @@ const {
|
||||
updateAttendees, sendAttendeeFormLink,
|
||||
} = require('../controllers/registrationController');
|
||||
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Campus-scoped staff (utils/campusScope.js). Routes open to the registration's owner as well
|
||||
// as to staff check staff against the registration's event, and let the owner through (ownerOk).
|
||||
|
||||
// Registration - auth optional when event doesn't require it
|
||||
router.post('/', optionalAuth, createRegistration);
|
||||
router.get('/myregistrations', protect, getUserRegistrations);
|
||||
router.put('/:id/options', protect, updateRegistrationOptions);
|
||||
router.delete('/:id', protect, cancelRegistration);
|
||||
router.post('/:id/send-invoice', protect, sendInvoice);
|
||||
router.post('/:id/coupon', protect, applyCoupon);
|
||||
router.delete('/:id/coupon', protect, staff, removeCoupon);
|
||||
router.put('/:id/attendees', protect, updateAttendees);
|
||||
router.post('/:id/attendees/:attendeeId/send-form-link', protect, sendAttendeeFormLink);
|
||||
router.put('/:id/options', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), updateRegistrationOptions);
|
||||
router.delete('/:id', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), cancelRegistration);
|
||||
router.post('/:id/send-invoice', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), sendInvoice);
|
||||
router.post('/:id/coupon', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), applyCoupon);
|
||||
router.delete('/:id/coupon', protect, staff, scopeEvent({ resolve: 'registration' }), removeCoupon);
|
||||
router.put('/:id/attendees', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), updateAttendees);
|
||||
router.post('/:id/attendees/:attendeeId/send-form-link', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), sendAttendeeFormLink);
|
||||
|
||||
// Registration detail + forms — optionalAuth so guests can access with just the registrationId
|
||||
router.get('/:id', optionalAuth, getRegistrationById);
|
||||
router.get('/:id/forms/draft', optionalAuth, getFormDraft);
|
||||
router.put('/:id/forms/draft', optionalAuth, saveFormDraft);
|
||||
router.post('/:id/forms/responses', optionalAuth, submitFormResponses);
|
||||
router.put('/:id/forms/responses', protect, supervisor, replaceFormResponses);
|
||||
router.get('/:id', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), getRegistrationById);
|
||||
router.get('/:id/forms/draft', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), getFormDraft);
|
||||
router.put('/:id/forms/draft', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), saveFormDraft);
|
||||
router.post('/:id/forms/responses', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), submitFormResponses);
|
||||
router.put('/:id/forms/responses', protect, supervisor, scopeEvent({ resolve: 'registration' }), replaceFormResponses);
|
||||
|
||||
// Admin/Staff routes
|
||||
router.get('/', protect, staff, getRegistrations);
|
||||
router.put('/:id', protect, staff, updateRegistrationStatus);
|
||||
router.get('/event/:eventId', protect, staff, getRegistrationsByEvent);
|
||||
router.post('/manual', protect, supervisor, createManualRegistration);
|
||||
router.get('/', protect, staff, scopeList, getRegistrations);
|
||||
router.put('/:id', protect, staff, scopeEvent({ resolve: 'registration' }), updateRegistrationStatus);
|
||||
router.get('/event/:eventId', protect, staff, scopeEvent(), getRegistrationsByEvent);
|
||||
router.post('/manual', protect, supervisor, scopeEvent({ from: req => req.body?.eventId }), createManualRegistration);
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,14 +2,15 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listLocks, createLock, getSnapshot, syncLock, releaseLock, forceRelease, listReports, reviewReport } = require('../controllers/scanLockController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
router.get('/', protect, staff, listLocks);
|
||||
router.post('/', protect, staff, createLock);
|
||||
router.get('/reports', protect, supervisor, listReports);
|
||||
router.post('/reports/:id/review', protect, supervisor, reviewReport);
|
||||
router.get('/:id/snapshot', protect, staff, getSnapshot);
|
||||
router.post('/:id/sync', protect, staff, syncLock);
|
||||
router.post('/:id/force-release', protect, supervisor, forceRelease);
|
||||
router.delete('/:id', protect, staff, releaseLock);
|
||||
router.get('/', protect, staff, scopeEvent({ from: req => req.query.eventId }), listLocks);
|
||||
router.post('/', protect, staff, scopeEvent({ from: req => req.body?.eventId }), createLock);
|
||||
router.get('/reports', protect, supervisor, scopeEvent({ from: req => req.query.eventId }), listReports);
|
||||
router.post('/reports/:id/review', protect, supervisor, scopeEvent({ resolve: 'syncReport' }), reviewReport);
|
||||
router.get('/:id/snapshot', protect, staff, scopeEvent({ resolve: 'scanLock' }), getSnapshot);
|
||||
router.post('/:id/sync', protect, staff, scopeEvent({ resolve: 'scanLock' }), syncLock);
|
||||
router.post('/:id/force-release', protect, supervisor, scopeEvent({ resolve: 'scanLock' }), forceRelease);
|
||||
router.delete('/:id', protect, staff, scopeEvent({ resolve: 'scanLock' }), releaseLock);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,10 +2,11 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listScheduledEmails, updateScheduledEmail, deleteScheduledEmail } = require('../controllers/scheduledEmailsController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeList } = require('../utils/campusScope');
|
||||
|
||||
// All routes require supervisor (or admin via middleware logic) access
|
||||
router.get('/', protect, supervisor, listScheduledEmails);
|
||||
router.patch('/:id', protect, supervisor, updateScheduledEmail);
|
||||
router.delete('/:id', protect, supervisor, deleteScheduledEmail);
|
||||
router.get('/', protect, supervisor, scopeList, listScheduledEmails);
|
||||
router.patch('/:id', protect, supervisor, scopeList, updateScheduledEmail);
|
||||
router.delete('/:id', protect, supervisor, scopeList, deleteScheduledEmail);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -7,10 +7,11 @@ const {
|
||||
updateSection
|
||||
} = require('../controllers/sectionController');
|
||||
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
router.get('/', protect, staff, getSections);
|
||||
router.post('/', protect, supervisor, createSection);
|
||||
router.get('/', protect, staff, scopeList, getSections);
|
||||
router.post('/', protect, supervisor, scopeEvent({ from: req => req.body?.eventId, need: 'manage' }), createSection);
|
||||
router.delete('/:id', protect, admin, deleteSection);
|
||||
router.put('/:id', protect, supervisor, updateSection);
|
||||
router.put('/:id', protect, supervisor, scopeEvent({ resolve: 'section', need: 'manage' }), updateSection);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,9 +2,10 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventSponsorship, createSponsorCheckout, getPool } = require('../controllers/sponsorshipController');
|
||||
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
router.get('/event/:eventId', getEventSponsorship);
|
||||
router.get('/event/:eventId/pool', protect, supervisor, getPool);
|
||||
router.get('/event/:eventId/pool', protect, supervisor, scopeEvent(), getPool);
|
||||
router.post('/checkout', optionalAuth, createSponsorCheckout);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,16 +2,17 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getStaffDashboardStats, getSupervisorDashboardStats, getAdminDashboardStats, getOverviewStats } = require('../controllers/statsController');
|
||||
const { protect, staff, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
const { scopeList } = require('../utils/campusScope');
|
||||
|
||||
// One endpoint per dashboard — each returns exactly what that dashboard renders in a
|
||||
// single response instead of the dashboard making several separate calls (and, previously,
|
||||
// pulling full payments/events lists just to reduce them to a couple of numbers).
|
||||
router.get('/staff', protect, staff, getStaffDashboardStats);
|
||||
router.get('/supervisor', protect, supervisor, getSupervisorDashboardStats);
|
||||
router.get('/staff', protect, staff, scopeList, getStaffDashboardStats);
|
||||
router.get('/supervisor', protect, supervisor, scopeList, getSupervisorDashboardStats);
|
||||
router.get('/admin', protect, admin, getAdminDashboardStats);
|
||||
|
||||
// Month-over-month KPIs + trend + top events, shared by the Admin and Supervisor dashboards.
|
||||
// Staff never sees financial data, so this has no staff-accessible route.
|
||||
router.get('/overview', protect, supervisor, getOverviewStats);
|
||||
router.get('/overview', protect, supervisor, scopeList, getOverviewStats);
|
||||
|
||||
module.exports = router;
|
||||
@@ -22,31 +22,34 @@ const {
|
||||
getTicketTransfers
|
||||
} = require('../controllers/ticketController');
|
||||
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeBody, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Campus-scoped staff (utils/campusScope.js). The scanner names the campus of another campus's ticket.
|
||||
|
||||
// Protected routes
|
||||
router.get('/mytickets', protect, getUserTickets);
|
||||
router.get('/:id', protect, getTicketById);
|
||||
router.get('/:id', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), getTicketById);
|
||||
router.post('/email', protect, emailTickets);
|
||||
router.post('/:id/transfer', protect, transferTicketHandler);
|
||||
router.delete('/:id/transfer', protect, cancelTransferHandler);
|
||||
router.post('/:id/transfer', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), transferTicketHandler);
|
||||
router.delete('/:id/transfer', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), cancelTransferHandler);
|
||||
// Transfer links (public: the person the ticket is offered to)
|
||||
router.get('/transfer-offer/:token', getTransferOffer);
|
||||
router.post('/transfer-offer/:token/accept', acceptTransferOffer);
|
||||
router.post('/transfer-offer/:token/decline', declineTransferOffer);
|
||||
router.get('/:id/transfers', protect, getTicketTransfers);
|
||||
router.get('/:id/transfers', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), getTicketTransfers);
|
||||
|
||||
// Staff routes
|
||||
router.post('/send-to', protect, staff, sendTicketsTo);
|
||||
router.get('/scan-preview/:qrCode', protect, staff, getScanPreview);
|
||||
router.get('/qr/:qrCode', protect, staff, getTicketByQrCode);
|
||||
router.post('/scan/:qrCode', protect, staff, scanTicket);
|
||||
router.get('/event/:eventId', protect, staff, getTicketsByEvent);
|
||||
router.get('/scans/recent', protect, staff, getRecentScans);
|
||||
router.get('/scans/stats', protect, staff, getScanStats);
|
||||
router.post('/send-to', protect, staff, scopeBody([['registrationId', 'registration'], ['ticketIds', 'ticket']]), sendTicketsTo);
|
||||
router.get('/scan-preview/:qrCode', protect, staff, scopeEvent({ resolve: 'ticketQr', from: req => req.params.qrCode, nameCampus: true }), getScanPreview);
|
||||
router.get('/qr/:qrCode', protect, staff, scopeEvent({ resolve: 'ticketQr', from: req => req.params.qrCode, nameCampus: true }), getTicketByQrCode);
|
||||
router.post('/scan/:qrCode', protect, staff, scopeEvent({ resolve: 'ticketQr', from: req => req.params.qrCode, nameCampus: true }), scanTicket);
|
||||
router.get('/event/:eventId', protect, staff, scopeEvent(), getTicketsByEvent);
|
||||
router.get('/scans/recent', protect, staff, scopeList, getRecentScans);
|
||||
router.get('/scans/stats', protect, staff, scopeList, getScanStats);
|
||||
|
||||
// Admin routes
|
||||
router.post('/generate', protect, supervisor, generateTickets);
|
||||
router.get('/', protect, supervisor, getTickets);
|
||||
router.post('/generate', protect, supervisor, scopeBody([['registrationId', 'registration']]), generateTickets);
|
||||
router.get('/', protect, supervisor, scopeList, getTickets);
|
||||
router.put('/email-sent', protect, admin, markTicketsAsEmailSent);
|
||||
|
||||
module.exports = router;
|
||||
@@ -3,12 +3,13 @@ const router = express.Router();
|
||||
|
||||
const { upload, uploadEventImage, uploadLogo, uploadLogoImage, uploadFavicon, uploadFaviconImage } = require('../controllers/uploadController');
|
||||
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
const { campusIndependent } = require('../utils/campusScope');
|
||||
const prisma = require('../config/db');
|
||||
|
||||
// @route POST /api/uploads/event-image
|
||||
// @desc Upload an image for an event
|
||||
// @access Private (Supervisor+ recommended)
|
||||
router.post('/event-image', protect, supervisor, (req, res, next) => {
|
||||
router.post('/event-image', protect, supervisor, campusIndependent('stores an image file; the event it goes on is checked when the event is saved'), (req, res, next) => {
|
||||
upload.single('image')(req, res, (err) => {
|
||||
if (err) {
|
||||
req.multerError = err;
|
||||
|
||||
@@ -26,6 +26,7 @@ const {
|
||||
getMyActivity,
|
||||
} = require('../controllers/userController');
|
||||
const { protect, admin, supervisor, loginLimiter} = require('../middleware/authMiddleware');
|
||||
const { campusIndependent } = require('../utils/campusScope');
|
||||
|
||||
// Public routes
|
||||
router.post('/', registerUser);
|
||||
@@ -51,9 +52,9 @@ router.post('/2fa/disable', protect, loginLimiter, disableTwoFactor);
|
||||
|
||||
// Admin routes
|
||||
router.route('/')
|
||||
.get(protect, supervisor, getUsers);
|
||||
.get(protect, supervisor, campusIndependent('accounts are shared across campuses'), getUsers);
|
||||
|
||||
router.get('/check-exists', protect, supervisor, checkUserExists);
|
||||
router.get('/check-exists', protect, supervisor, campusIndependent('accounts are shared across campuses'), checkUserExists);
|
||||
|
||||
router.route('/:id')
|
||||
.get(protect, admin, getUserById)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const c = require('../controllers/volunteerController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// My account → Serving (anyone with an account can volunteer; only their own slots)
|
||||
router.get('/me', protect, c.getMine);
|
||||
router.get('/events/:eventId', protect, c.getEventRoles);
|
||||
router.post('/roles/:id/sign-up', protect, c.signUp);
|
||||
router.delete('/slots/:id', protect, c.withdraw);
|
||||
router.post('/slots/:id/swap', protect, c.requestSwap);
|
||||
router.delete('/swaps/:id', protect, c.cancelSwap);
|
||||
router.post('/swaps/:id/accept', protect, c.acceptSwap);
|
||||
router.post('/check-in', protect, c.checkIn);
|
||||
|
||||
// The roster on the day (staff)
|
||||
router.get('/manage/events', protect, staff, scopeList, c.listEvents);
|
||||
router.get('/manage/events/:eventId', protect, staff, scopeEvent(), c.getRoster);
|
||||
router.get('/manage/events/:eventId/qr', protect, staff, scopeEvent(), c.getQr);
|
||||
router.post('/manage/slots/:id/check-in', protect, staff, scopeEvent({ resolve: 'volunteerSlot' }), c.checkInManual);
|
||||
|
||||
// Roles, people, settings and reports (supervisors)
|
||||
router.get('/manage/events/:eventId/settings', protect, supervisor, scopeEvent(), c.getSettings);
|
||||
router.put('/manage/events/:eventId/settings', protect, supervisor, scopeEvent({ need: 'manage' }), c.saveSettings);
|
||||
router.post('/manage/events/:eventId/roles', protect, supervisor, scopeEvent({ need: 'manage' }), c.createRole);
|
||||
router.put('/manage/roles/:id', protect, supervisor, scopeEvent({ resolve: 'servingRole', need: 'manage' }), c.updateRole);
|
||||
router.delete('/manage/roles/:id', protect, supervisor, scopeEvent({ resolve: 'servingRole', need: 'manage' }), c.deleteRole);
|
||||
router.post('/manage/roles/:id/assign', protect, supervisor, scopeEvent({ resolve: 'servingRole' }), c.assign);
|
||||
router.delete('/manage/slots/:id', protect, supervisor, scopeEvent({ resolve: 'volunteerSlot' }), c.remove);
|
||||
router.get('/manage/hours', protect, supervisor, scopeList, c.hours);
|
||||
|
||||
module.exports = router;
|
||||
@@ -3,6 +3,7 @@ const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const { joinWaitlist, myWaitlist, leaveWaitlist, claimInfo, eventWaitlist, offerManually, moveEntry, removeEntry } = require('../controllers/waitlistController');
|
||||
const { protect, staff, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
// Claim links are credentials, so keep guessing slow.
|
||||
const claimLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
|
||||
@@ -10,10 +11,10 @@ const claimLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { messag
|
||||
router.post('/', optionalAuth, joinWaitlist);
|
||||
router.get('/mine', protect, myWaitlist);
|
||||
router.get('/claim/:token', claimLimiter, claimInfo);
|
||||
router.get('/events/:eventId', protect, staff, eventWaitlist);
|
||||
router.post('/:id/offer', protect, supervisor, offerManually);
|
||||
router.post('/:id/move', protect, supervisor, moveEntry);
|
||||
router.post('/:id/remove', protect, supervisor, removeEntry);
|
||||
router.get('/events/:eventId', protect, staff, scopeEvent(), eventWaitlist);
|
||||
router.post('/:id/offer', protect, supervisor, scopeEvent({ resolve: 'waitlistEntry' }), offerManually);
|
||||
router.post('/:id/move', protect, supervisor, scopeEvent({ resolve: 'waitlistEntry' }), moveEntry);
|
||||
router.post('/:id/remove', protect, supervisor, scopeEvent({ resolve: 'waitlistEntry' }), removeEntry);
|
||||
router.delete('/:id', protect, leaveWaitlist);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,9 +2,10 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { previewWhatsAppBroadcast, sendWhatsAppBroadcast, scheduleWhatsAppBroadcast } = require('../controllers/whatsappBroadcastController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeBroadcast } = require('../utils/campusScope');
|
||||
|
||||
router.post('/preview', protect, supervisor, previewWhatsAppBroadcast);
|
||||
router.post('/send', protect, supervisor, sendWhatsAppBroadcast);
|
||||
router.post('/schedule', protect, supervisor, scheduleWhatsAppBroadcast);
|
||||
router.post('/preview', protect, supervisor, scopeBroadcast({ freeformField: 'phones' }), previewWhatsAppBroadcast);
|
||||
router.post('/send', protect, supervisor, scopeBroadcast({ freeformField: 'phones' }), sendWhatsAppBroadcast);
|
||||
router.post('/schedule', protect, supervisor, scopeBroadcast({ freeformField: 'phones' }), scheduleWhatsAppBroadcast);
|
||||
|
||||
module.exports = router;
|
||||
@@ -15,14 +15,24 @@ const ACCESS_ACTIONS = {
|
||||
medical_list: 'Medical list viewed',
|
||||
backup_download: 'Database backup downloaded',
|
||||
impersonation: 'Viewed the site as another user',
|
||||
kids_rooms: 'Kids check-in desk or room opened',
|
||||
kids_offline: 'Kids check-in list downloaded for offline use',
|
||||
volunteer_roster: 'Volunteer roster opened',
|
||||
};
|
||||
|
||||
/**
|
||||
* POPIA access log: someone exported or viewed personal information in bulk. Fire-and-
|
||||
* forget like logAdminAction — a logging failure never blocks the export itself.
|
||||
*/
|
||||
async function logDataAccess(req, { action, what, eventId = null, records = null }) {
|
||||
async function logDataAccess(req, { action, what, eventId = null, records = null, sensitive }) {
|
||||
try {
|
||||
// Children's or special information: medical lists always; otherwise anything about a
|
||||
// youth event (one that asks for parental consent).
|
||||
let isSensitive = sensitive ?? (action === 'medical_list');
|
||||
if (!isSensitive && eventId) {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { requiresParentalConsent: true } });
|
||||
isSensitive = !!event?.requiresParentalConsent;
|
||||
}
|
||||
await prisma.dataAccessLog.create({
|
||||
data: {
|
||||
actorId: req.user?.id || null,
|
||||
@@ -31,6 +41,7 @@ async function logDataAccess(req, { action, what, eventId = null, records = null
|
||||
what: String(what || ACCESS_ACTIONS[action] || action).slice(0, 500),
|
||||
eventId: eventId || null,
|
||||
records: Number.isFinite(records) ? records : null,
|
||||
sensitive: isSensitive,
|
||||
ip: getClientIp(req),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Campus-scoped staff (1.44.0). A supervisor or staff member can be limited to some campuses
|
||||
* (Admin → Users → "Limit to campuses"). They then see their campuses' events plus events for
|
||||
* all campuses (Event.campusId null) — and can work on those church-wide events (view, scan,
|
||||
* door payments, manual registrations) but not set them up, cash them up or refund them.
|
||||
*
|
||||
* Unscoped (no limits) = everything, as before. Admins are never scoped, and scopes are ignored
|
||||
* while campuses are off (Site settings → Campuses).
|
||||
*
|
||||
* How a route is checked — every staff/supervisor route carries one of these markers, and
|
||||
* tests/campusScopeRoutes.test.js fails on any route that doesn't:
|
||||
* scopeEvent({ resolve, from, need }) the route works on one event (or a record of one)
|
||||
* scopeList the controller filters (listWhere) or checks it itself
|
||||
* unscopedOnly admins and unscoped supervisors only
|
||||
* campusIndependent('reason') nothing to do with any event's campus
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { campusesEnabled } = require('./campuses');
|
||||
|
||||
const STAFF_ROLES = new Set(['supervisor', 'staff']);
|
||||
|
||||
/**
|
||||
* The scope of the signed-in user: null (unscoped) or { campusIds, names }. Worked out once
|
||||
* per request.
|
||||
*/
|
||||
async function scopeFor(req) {
|
||||
if (req._campusScope !== undefined) return req._campusScope;
|
||||
let scope = null;
|
||||
if (req.user && STAFF_ROLES.has(req.user.role) && await campusesEnabled()) {
|
||||
const row = await prisma.user.findUnique({ where: { id: req.user.id }, select: { scopedCampuses: { select: { id: true, name: true } } } });
|
||||
const campuses = row?.scopedCampuses || [];
|
||||
if (campuses.length) scope = { campusIds: campuses.map(c => c.id), names: campuses.map(c => c.name) };
|
||||
}
|
||||
req._campusScope = scope;
|
||||
return scope;
|
||||
}
|
||||
|
||||
/** The `where` on events for a scope: its campuses plus church-wide events. {} when unscoped. */
|
||||
function eventWhere(scope) {
|
||||
if (!scope) return {};
|
||||
return { OR: [{ campusId: { in: scope.campusIds } }, { campusId: null }] };
|
||||
}
|
||||
|
||||
/** The `where` on events a scope may manage: only its campuses' (not church-wide). {} when unscoped. */
|
||||
function manageWhere(scope) {
|
||||
if (!scope) return {};
|
||||
return { campusId: { in: scope.campusIds } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The same, through a relation path: relWhere(scope, 'event') → { event: {...} },
|
||||
* relWhere(scope, 'registration.event') → { registration: { event: {...} } }.
|
||||
*/
|
||||
function relWhere(scope, path) {
|
||||
if (!scope) return {};
|
||||
return path.split('.').reverse().reduce((inner, key) => ({ [key]: inner }), eventWhere(scope));
|
||||
}
|
||||
|
||||
/** Can this scope see/work on an event at `campusId` (null = all campuses)? */
|
||||
function canView(scope, campusId) {
|
||||
return !scope || campusId == null || scope.campusIds.includes(campusId);
|
||||
}
|
||||
|
||||
/** Can this scope set up, cash up or refund an event at `campusId`? Not church-wide ones. */
|
||||
function canManage(scope, campusId) {
|
||||
return !scope || (campusId != null && scope.campusIds.includes(campusId));
|
||||
}
|
||||
|
||||
// id → { id, campusId, campusName } of the event a record belongs to, or null if there's no such
|
||||
// record. The event select is shared so every resolver answers the same shape.
|
||||
// `owners` are the users the record belongs to, for scopeEvent({ ownerOk }).
|
||||
const EV = { select: { id: true, campusId: true, campus: { select: { name: true } } } };
|
||||
const shape = (ev, owners = []) => (ev ? { id: ev.id, campusId: ev.campusId, campusName: ev.campus?.name || null, owners: owners.filter(Boolean) } : null);
|
||||
|
||||
const RESOLVERS = {
|
||||
event: async id => shape(await prisma.event.findUnique({ where: { id }, ...EV })),
|
||||
registration: async id => {
|
||||
const r = await prisma.registration.findUnique({ where: { id }, select: { userId: true, event: EV } });
|
||||
return shape(r?.event, [r?.userId]);
|
||||
},
|
||||
ticket: async id => {
|
||||
const t = await prisma.ticket.findUnique({ where: { id }, select: { userId: true, holderUserId: true, event: EV } });
|
||||
return shape(t?.event, [t?.userId, t?.holderUserId]);
|
||||
},
|
||||
ticketQr: async qrCode => shape((await prisma.ticket.findUnique({ where: { qrCode }, select: { event: EV } }))?.event),
|
||||
payment: async id => {
|
||||
const p = await prisma.payment.findUnique({ where: { id }, select: { userId: true, event: EV, registration: { select: { event: EV } } } });
|
||||
return shape(p?.event || p?.registration?.event, [p?.userId]);
|
||||
},
|
||||
eventOption: async id => shape((await prisma.eventOption.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
optionVariant: async id => shape((await prisma.optionVariant.findUnique({ where: { id }, select: { eventOption: { select: { event: EV } } } }))?.eventOption?.event),
|
||||
bundle: async id => shape((await prisma.eventBundle.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
allocationSet: async id => shape((await prisma.allocationSet.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
allocationGroup: async id => shape((await prisma.allocationGroup.findUnique({ where: { id }, select: { set: { select: { event: EV } } } }))?.set?.event),
|
||||
waitlistEntry: async id => shape((await prisma.waitlistEntry.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
scanLock: async id => shape((await prisma.scanLock.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
syncReport: async id => shape((await prisma.offlineSyncReport.findUnique({ where: { id }, select: { scanLock: { select: { event: EV } } } }))?.scanLock?.event),
|
||||
section: async id => shape((await prisma.section.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
certificate: async id => shape((await prisma.certificate.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
consentRequest: async id => {
|
||||
const c = await prisma.consentRequest.findUnique({ where: { id }, select: { event: EV, attendee: { select: { registration: { select: { userId: true } } } } } });
|
||||
return shape(c?.event, [c?.attendee?.registration?.userId]);
|
||||
},
|
||||
kidsCheckIn: async id => shape((await prisma.kidsCheckIn.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
kidsAlert: async id => shape((await prisma.kidsAlert.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
servingRole: async id => shape((await prisma.servingRole.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
volunteerSlot: async id => shape((await prisma.volunteerSlot.findUnique({ where: { id }, select: { role: { select: { event: EV } } } }))?.role?.event),
|
||||
};
|
||||
|
||||
/** The event a record belongs to (see RESOLVERS), or null. */
|
||||
async function eventOf(kind, id) {
|
||||
const resolve = RESOLVERS[kind];
|
||||
if (!resolve) throw new Error(`No campus resolver for "${kind}"`);
|
||||
if (!id) return null;
|
||||
return resolve(String(id));
|
||||
}
|
||||
|
||||
const NOT_FOUND = { message: 'Not found' };
|
||||
const CHURCH_WIDE_MSG = 'This is an event for all campuses — only admins and supervisors for every campus can change it.';
|
||||
|
||||
/**
|
||||
* Check a scoped user against one event, found from the request. Out of scope → 404 (another
|
||||
* campus's records don't leak); a church-wide event when `need: 'manage'` → 403. Records that
|
||||
* don't exist pass through so the controller answers as it always has.
|
||||
* resolve which RESOLVERS entry turns the id into its event (default 'event')
|
||||
* from req → id (default req.params.eventId || req.params.id)
|
||||
* need 'view' (default) or 'manage'
|
||||
* ownerOk let the record's own user through (a staff member's own registration for another
|
||||
* campus's event) — for routes open to the owner as well as to staff
|
||||
* nameCampus answer 403 naming the event's campus instead of 404 (the ticket scanner: the
|
||||
* person at the door needs to know where the ticket belongs)
|
||||
*/
|
||||
function scopeEvent({ resolve = 'event', from = req => req.params.eventId || req.params.id, need = 'view', ownerOk = false, nameCampus = false } = {}) {
|
||||
if (!RESOLVERS[resolve]) throw new Error(`No campus resolver for "${resolve}"`);
|
||||
const mw = async (req, res, next) => {
|
||||
try {
|
||||
const scope = await scopeFor(req);
|
||||
if (!scope) return next();
|
||||
const ev = await eventOf(resolve, from(req));
|
||||
if (!ev) return next();
|
||||
if (ownerOk && ev.owners.includes(req.user.id)) return next();
|
||||
if (!canView(scope, ev.campusId)) {
|
||||
if (nameCampus) return res.status(403).json({ code: 'OTHER_CAMPUS', message: `This ticket is for an event at ${ev.campusName || 'another campus'}.` });
|
||||
return res.status(404).json(NOT_FOUND);
|
||||
}
|
||||
if (need === 'manage' && !canManage(scope, ev.campusId)) return res.status(403).json({ code: 'CHURCH_WIDE_EVENT', message: CHURCH_WIDE_MSG });
|
||||
req.scopedEvent = ev;
|
||||
return next();
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
};
|
||||
mw.campusScope = `event:${resolve}:${need}${ownerOk ? ':owner-ok' : ''}`;
|
||||
return mw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like scopeEvent, for records named in the request body: `targets` is [[field, resolver], …]
|
||||
* (e.g. [['registrationId', 'registration'], ['eventId', 'event']]). Every target that's given
|
||||
* is checked. With `orphan: 'deny'`, a scoped user is refused when none of them leads to an
|
||||
* event (a donation not linked to any event is for admins and unscoped supervisors only).
|
||||
*/
|
||||
function scopeBody(targets, { need = 'view', orphan = 'pass' } = {}) {
|
||||
for (const [, resolve] of targets) if (!RESOLVERS[resolve]) throw new Error(`No campus resolver for "${resolve}"`);
|
||||
const mw = async (req, res, next) => {
|
||||
try {
|
||||
const scope = await scopeFor(req);
|
||||
if (!scope) return next();
|
||||
let found = false;
|
||||
for (const [field, resolve] of targets) {
|
||||
const value = req.body?.[field];
|
||||
// A list (e.g. ticketIds) checks every id in it.
|
||||
for (const id of (Array.isArray(value) ? value : [value]).filter(Boolean).slice(0, 500)) {
|
||||
const ev = await eventOf(resolve, id);
|
||||
if (!ev) continue;
|
||||
found = true;
|
||||
if (!canView(scope, ev.campusId)) return res.status(404).json(NOT_FOUND);
|
||||
if (need === 'manage' && !canManage(scope, ev.campusId)) return res.status(403).json({ code: 'CHURCH_WIDE_EVENT', message: CHURCH_WIDE_MSG });
|
||||
}
|
||||
}
|
||||
if (!found && orphan === 'deny') {
|
||||
return res.status(403).json({ code: 'CAMPUS_SCOPED', message: "Only admins and supervisors for every campus can work with payments that aren't for an event." });
|
||||
}
|
||||
return next();
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
};
|
||||
mw.campusScope = `body:${targets.map(t => t.join('→')).join(',')}:${need}:${orphan}`;
|
||||
return mw;
|
||||
}
|
||||
|
||||
/** Marker: the controller filters what it lists with listWhere(). */
|
||||
const scopeList = (req, res, next) => next();
|
||||
scopeList.campusScope = 'list';
|
||||
|
||||
/** Admins and unscoped supervisors/staff only. */
|
||||
const unscopedOnly = async (req, res, next) => {
|
||||
try {
|
||||
if (await scopeFor(req)) return res.status(403).json({ code: 'CAMPUS_SCOPED', message: 'Only admins and supervisors for every campus can do this.' });
|
||||
return next();
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
};
|
||||
unscopedOnly.campusScope = 'unscoped-only';
|
||||
|
||||
/** Marker for a route that has nothing to do with any event's campus; the reason is required. */
|
||||
function campusIndependent(reason) {
|
||||
if (!reason) throw new Error('campusIndependent needs a reason');
|
||||
const mw = (req, res, next) => next();
|
||||
mw.campusScope = `independent: ${reason}`;
|
||||
return mw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine a controller's `where` with a scope `where` without either overwriting the other's
|
||||
* OR/AND (spreading would): { AND: [where, extra] }, or `where` itself when there's no scope.
|
||||
*/
|
||||
function withScope(where, extra) {
|
||||
if (!extra || !Object.keys(extra).length) return where;
|
||||
if (!where || !Object.keys(where).length) return extra;
|
||||
return { AND: [where, extra] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts (email/WhatsApp, send/preview/schedule): a scoped supervisor may only message
|
||||
* people at their campuses (home campus) or attendees of events they can see, and can't add
|
||||
* free-typed addresses (`freeformField`: 'emails' or 'phones'). The placeholder event must be
|
||||
* one they can see.
|
||||
*/
|
||||
function scopeBroadcast({ freeformField }) {
|
||||
const mw = async (req, res, next) => {
|
||||
try {
|
||||
const scope = await scopeFor(req);
|
||||
if (!scope) return next();
|
||||
const body = req.body || {};
|
||||
if (String(body[freeformField] || '').trim()) {
|
||||
return res.status(403).json({ code: 'CAMPUS_SCOPED', message: 'Supervisors for some campuses can only message people from the list, not typed-in addresses.' });
|
||||
}
|
||||
if (body.eventId) {
|
||||
const ev = await eventOf('event', body.eventId);
|
||||
if (ev && !canView(scope, ev.campusId)) return res.status(404).json(NOT_FOUND);
|
||||
}
|
||||
const ids = Array.isArray(body.userIds) ? [...new Set(body.userIds.filter(x => typeof x === 'string' && x))] : [];
|
||||
if (ids.length) {
|
||||
const allowed = await prisma.user.count({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
OR: [
|
||||
{ homeCampusId: { in: scope.campusIds } },
|
||||
{ registrations: { some: { event: eventWhere(scope) } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
if (allowed !== ids.length) {
|
||||
const n = ids.length - allowed;
|
||||
return res.status(403).json({ code: 'CAMPUS_SCOPED', message: `${n} of the people selected ${n === 1 ? "isn't" : "aren't"} at your campuses or registered for your events.` });
|
||||
}
|
||||
}
|
||||
return next();
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
};
|
||||
mw.campusScope = 'broadcast';
|
||||
return mw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coupons: a scoped supervisor may see and change a coupon only when it's limited to events,
|
||||
* all at their campuses (a coupon for every event, or a church-wide event, is unscoped-only).
|
||||
* `campusIds` are the campuses of the coupon's events.
|
||||
*/
|
||||
function couponAllowed(scope, campusIds) {
|
||||
if (!scope) return true;
|
||||
return campusIds.length > 0 && campusIds.every(id => id != null && scope.campusIds.includes(id));
|
||||
}
|
||||
|
||||
/** The `where` for a list endpoint: relWhere through `path` ('' = the event itself). */
|
||||
async function listWhere(req, path = '') {
|
||||
const scope = await scopeFor(req);
|
||||
return path ? relWhere(scope, path) : eventWhere(scope);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
scopeFor, eventWhere, manageWhere, relWhere, canView, canManage, eventOf, RESOLVERS,
|
||||
scopeEvent, scopeBody, scopeList, unscopedOnly, campusIndependent, scopeBroadcast, couponAllowed, listWhere, withScope, CHURCH_WIDE_MSG,
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Multi-campus (Site settings → Campuses, off by default). Events belong to one campus, or to
|
||||
* all campuses when Event.campusId is null. While campuses are off, campus fields and filters
|
||||
* are ignored everywhere (the data stays, hidden). Campus-scoped staff come in 1.44.0.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { getSetting } = require('./settingsCache');
|
||||
|
||||
const SETTING_KEY = 'campuses_enabled';
|
||||
|
||||
// What the public site may see of a campus.
|
||||
const CAMPUS_PUBLIC_SELECT = { id: true, name: true, slug: true, address: true, colour: true, logoUrl: true, defaultLocation: true, contactName: true, contactEmail: true, contactPhone: true, order: true };
|
||||
// What an event carries about its campus (cards, tickets, lists).
|
||||
const EVENT_CAMPUS_SELECT = { id: true, name: true, slug: true, colour: true };
|
||||
|
||||
async function campusesEnabled() {
|
||||
return String(await getSetting(SETTING_KEY, 'false')).toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
/** "Hope Centre — Henley" → "hope-centre-henley". */
|
||||
function slugify(text) {
|
||||
return String(text || '')
|
||||
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
const trimOrNull = (v, max = 200) => {
|
||||
if (v === undefined) return undefined;
|
||||
const s = String(v ?? '').trim();
|
||||
return s ? s.slice(0, max) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check and clean the fields of a campus from a request body. Returns { data } or { error }.
|
||||
* `partial` (an update) only touches the fields that were sent.
|
||||
*/
|
||||
function cleanCampusInput(body = {}, { partial = false } = {}) {
|
||||
const data = {};
|
||||
if (!partial || body.name !== undefined) {
|
||||
const name = trimOrNull(body.name, 100);
|
||||
if (!name) return { error: 'Give the campus a name.' };
|
||||
data.name = name;
|
||||
}
|
||||
if (body.slug !== undefined || (!partial && data.name)) {
|
||||
const slug = slugify(body.slug || data.name);
|
||||
if (!slug) return { error: 'The web address needs letters or numbers.' };
|
||||
data.slug = slug;
|
||||
}
|
||||
for (const [key, max] of [['address', 300], ['contactName', 100], ['contactPhone', 40], ['defaultLocation', 300], ['logoUrl', 500]]) {
|
||||
const v = trimOrNull(body[key], max);
|
||||
if (v !== undefined) data[key] = v;
|
||||
}
|
||||
if (body.contactEmail !== undefined) {
|
||||
const email = trimOrNull(body.contactEmail, 200);
|
||||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { error: 'That contact email doesn\'t look right.' };
|
||||
data.contactEmail = email;
|
||||
}
|
||||
if (body.colour !== undefined) {
|
||||
const colour = trimOrNull(body.colour, 7);
|
||||
if (colour && !/^#[0-9a-fA-F]{6}$/.test(colour)) return { error: 'Use a colour like #1d4ed8.' };
|
||||
data.colour = colour ? colour.toLowerCase() : null;
|
||||
}
|
||||
return { data };
|
||||
}
|
||||
|
||||
/**
|
||||
* The `where` for a campus filter on a list of events.
|
||||
* filter ''/undefined → no filter (church-wide)
|
||||
* filter 'all' → only events for all campuses
|
||||
* filter <campus id> → that campus's events; `withAllCampuses` also includes events for all
|
||||
* campuses (the public list: a church-wide camp shows at every campus)
|
||||
* Returns {} when campuses are off, so callers can spread it unconditionally.
|
||||
*/
|
||||
function campusWhere(filter, { enabled, withAllCampuses = false } = {}) {
|
||||
if (!enabled || !filter) return {};
|
||||
if (filter === 'all') return { campusId: null };
|
||||
return withAllCampuses ? { OR: [{ campusId: String(filter) }, { campusId: null }] } : { campusId: String(filter) };
|
||||
}
|
||||
|
||||
/** A campus by id or slug (archived ones too), or null. */
|
||||
async function findCampus(idOrSlug) {
|
||||
if (!idOrSlug) return null;
|
||||
const v = String(idOrSlug);
|
||||
return prisma.campus.findFirst({ where: { OR: [{ id: v }, { slug: v }] } });
|
||||
}
|
||||
|
||||
/** Resolve a filter value from a query string (id, slug or 'all') to what campusWhere takes. */
|
||||
async function resolveCampusFilter(value) {
|
||||
if (!value) return '';
|
||||
if (value === 'all') return 'all';
|
||||
const campus = await findCampus(value);
|
||||
// An unknown campus matches nothing rather than silently showing everything.
|
||||
return campus ? campus.id : '__none__';
|
||||
}
|
||||
|
||||
/**
|
||||
* The campus to save on an event from a request's `campusId` (''/null = all campuses).
|
||||
* Returns { campusId } (undefined = leave as is) or { error }. Ignored while campuses are off.
|
||||
* An archived campus can stay on an event that already has it, but can't be newly chosen.
|
||||
*/
|
||||
async function campusForEvent(value, { enabled, currentCampusId = null } = {}) {
|
||||
if (!enabled || value === undefined) return { campusId: undefined };
|
||||
if (value === null || value === '' || value === 'all') return { campusId: null };
|
||||
const campus = await prisma.campus.findUnique({ where: { id: String(value) } });
|
||||
if (!campus) return { error: 'That campus doesn\'t exist.' };
|
||||
if (campus.archivedAt && campus.id !== currentCampusId) return { error: `${campus.name} is archived — choose another campus.` };
|
||||
return { campusId: campus.id };
|
||||
}
|
||||
|
||||
const AUDIENCE_MONTHS = 12;
|
||||
|
||||
/**
|
||||
* The `where` for "everyone at campus X" (broadcasts): people whose home campus is X, plus —
|
||||
* with `includeRegistrants` — people with a registration for one of X's events in the last
|
||||
* 12 months. Active real accounts only. News consent is still checked when sending.
|
||||
*/
|
||||
function campusAudienceWhere(campusId, { includeRegistrants = false, now = new Date() } = {}) {
|
||||
const since = new Date(now);
|
||||
since.setMonth(since.getMonth() - AUDIENCE_MONTHS);
|
||||
const who = [{ homeCampusId: campusId }];
|
||||
if (includeRegistrants) {
|
||||
who.push({ registrations: { some: { createdAt: { gte: since }, status: { not: 'cancelled' }, event: { campusId } } } });
|
||||
}
|
||||
return {
|
||||
isActive: true,
|
||||
NOT: [{ email: { endsWith: '@deleted.invalid' } }, { email: { endsWith: '@guest.local' } }],
|
||||
OR: who,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SETTING_KEY, AUDIENCE_MONTHS, CAMPUS_PUBLIC_SELECT, EVENT_CAMPUS_SELECT,
|
||||
campusesEnabled, slugify, cleanCampusInput, campusWhere, findCampus, resolveCampusFilter, campusForEvent, campusAudienceWhere,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
// Server errors (5xx responses) in the last 24 hours, for the Hub heartbeat. In memory per
|
||||
// process: a restart starts again from zero, which is fine for spotting a spike.
|
||||
const WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
const MAX_KEPT = 10000;
|
||||
let times = [];
|
||||
|
||||
function recordError(now = Date.now()) {
|
||||
times.push(now);
|
||||
if (times.length > MAX_KEPT) times = times.slice(-MAX_KEPT);
|
||||
}
|
||||
|
||||
function countLast24h(now = Date.now()) {
|
||||
times = times.filter(t => now - t < WINDOW_MS);
|
||||
return times.length;
|
||||
}
|
||||
|
||||
module.exports = { recordError, countLast24h };
|
||||
@@ -0,0 +1,844 @@
|
||||
/**
|
||||
* Kids check-in (1.45.0). Children are checked in at a staff desk, fully digitally:
|
||||
*
|
||||
* - Families (parent accounts, children, authorised adults) are kept between events.
|
||||
* - A check-in gives the family a pickup code for the occasion — a session, or the day on a
|
||||
* single-date event — shared by the children checked in together. The parent sees it on
|
||||
* their phone (My account → Kids) and gets it by WhatsApp/email; anyone with the code can
|
||||
* collect. The desk shows a "write on the sticker" panel instead of printing labels.
|
||||
* - Each child gets a free place on the event's kids option the first time (registration,
|
||||
* attendee, ticket under the parent), and every check-in writes a TicketUsage — so the
|
||||
* attendance register, reports and campus scoping treat them like any other attendee.
|
||||
* - Rooms are the groups of the event's "kids rooms" allocation set, with an age range,
|
||||
* capacity and a ratio (children per leader on duty).
|
||||
*
|
||||
* Allergies, medical and custody notes are encrypted and never put in messages.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const prisma = require('../config/db');
|
||||
const { encrypt, decrypt } = require('./encryption');
|
||||
const sessionsUtil = require('./sessions');
|
||||
|
||||
// No 0/O or 1/I, so a code read out or copied onto a sticker can't be misread.
|
||||
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const CODE_LENGTH = 4;
|
||||
const OFFLINE_CODES = 20;
|
||||
const MAX_TEXT = 1000;
|
||||
// What a pickup QR holds: the prefix and the code.
|
||||
const QR_PREFIX = 'KIDS-';
|
||||
|
||||
function kidsError(message, statusCode = 400, code = undefined) {
|
||||
return Object.assign(new Error(message), { statusCode, ...(code ? { code } : {}) });
|
||||
}
|
||||
|
||||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function randomCode(length = CODE_LENGTH) {
|
||||
const bytes = crypto.randomBytes(length);
|
||||
let out = '';
|
||||
for (let i = 0; i < length; i++) out += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A typed or scanned code, cleaned up: "kids-k7p2" / " k7 p2 " → "K7P2"; null if it can't be one. */
|
||||
function normaliseCode(raw) {
|
||||
let s = String(raw || '').trim().toUpperCase();
|
||||
if (s.startsWith(QR_PREFIX)) s = s.slice(QR_PREFIX.length);
|
||||
s = s.replace(/[\s-]/g, '');
|
||||
if (s.length !== CODE_LENGTH || [...s].some(c => !CODE_ALPHABET.includes(c))) return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Age in whole years on `on` (null without a date of birth). */
|
||||
function ageOn(dateOfBirth, on = new Date()) {
|
||||
if (!dateOfBirth) return null;
|
||||
const d = new Date(dateOfBirth), e = new Date(on);
|
||||
let age = e.getUTCFullYear() - d.getUTCFullYear();
|
||||
if (e.getUTCMonth() < d.getUTCMonth() || (e.getUTCMonth() === d.getUTCMonth() && e.getUTCDate() < d.getUTCDate())) age -= 1;
|
||||
return age;
|
||||
}
|
||||
|
||||
/** Whether a child of `age` fits a room's age range (no age or no range = fits). */
|
||||
function fitsRoom(room, age) {
|
||||
if (age == null) return true;
|
||||
if (room.minAge != null && age < room.minAge) return false;
|
||||
if (room.maxAge != null && age > room.maxAge) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The room to suggest for a child: rooms whose age range fits, not full, narrowest range
|
||||
* first (a 2–3 room beats a 0–12 room), then room order. `counts` = { roomId: children in }.
|
||||
* Returns null when nothing fits.
|
||||
*/
|
||||
function suggestRoom(rooms, age, counts = {}) {
|
||||
const span = r => (r.maxAge ?? 25) - (r.minAge ?? 0);
|
||||
const fits = rooms.filter(r => fitsRoom(r, age) && !(r.capacity && (counts[r.id] || 0) >= r.capacity));
|
||||
if (!fits.length) return null;
|
||||
return [...fits].sort((a, b) => (age == null ? 0 : span(a) - span(b)) || (a.order || 0) - (b.order || 0))[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* A room's state: children in, leaders on duty, how many leaders the ratio needs, and whether
|
||||
* it's full or over the ratio.
|
||||
*/
|
||||
function roomStatus(room, children, leaders) {
|
||||
const needed = room.ratio ? Math.ceil(children / room.ratio) : 0;
|
||||
return {
|
||||
children,
|
||||
leaders,
|
||||
leadersNeeded: needed,
|
||||
full: !!room.capacity && children >= room.capacity,
|
||||
overRatio: !!room.ratio && children > 0 && leaders * room.ratio < children,
|
||||
};
|
||||
}
|
||||
|
||||
/** The short alert copied onto a sticker: "ALLERGY: PEANUTS", "MEDICAL", or both; '' when none. */
|
||||
function stickerAlert(allergies, medicalNotes) {
|
||||
const parts = [];
|
||||
const a = String(allergies || '').trim();
|
||||
if (a) parts.push(`ALLERGY: ${a.toUpperCase().slice(0, 24)}${a.length > 24 ? '…' : ''}`);
|
||||
if (String(medicalNotes || '').trim()) parts.push('MEDICAL');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The occasion a check-in counts for: { occasion, sessionId }. On a multi-session event it's
|
||||
* today's session (or the one asked for, if it's open today); on a single-date event it's
|
||||
* today, while the event is on. Throws when check-in isn't open.
|
||||
*/
|
||||
function occasionFor(event, sessions, requestedSessionId = null, now = new Date()) {
|
||||
if (event.scheduleType === 'sessions') {
|
||||
const { session, error } = sessionsUtil.scanSession(event, sessions || [], requestedSessionId || null, now);
|
||||
if (!session) throw kidsError(error || 'No session is open for check-in today.');
|
||||
return { occasion: session.id, sessionId: session.id };
|
||||
}
|
||||
const today = sessionsUtil.dayKey(now);
|
||||
if (today < sessionsUtil.dayKey(event.startDate) || today > sessionsUtil.dayKey(event.endDate)) {
|
||||
throw kidsError(`Check-in for this event is open on ${sessionsUtil.dayKey(event.startDate)}${sessionsUtil.dayKey(event.endDate) !== sessionsUtil.dayKey(event.startDate) ? ` to ${sessionsUtil.dayKey(event.endDate)}` : ''}.`);
|
||||
}
|
||||
return { occasion: today, sessionId: null };
|
||||
}
|
||||
|
||||
const seal = v => {
|
||||
const s = String(v ?? '').trim().slice(0, MAX_TEXT);
|
||||
return s ? encrypt(s) : null;
|
||||
};
|
||||
const open = v => (v ? decrypt(v) || '' : '');
|
||||
|
||||
function childName(c) {
|
||||
return [c.firstName, c.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
/** A child for the API. `notes` adds medical and custody notes (staff and the child's parents). */
|
||||
function childView(c, { on = new Date(), notes = true } = {}) {
|
||||
const allergies = open(c.allergies);
|
||||
const medicalNotes = open(c.medicalNotes);
|
||||
return {
|
||||
id: c.id,
|
||||
firstName: c.firstName,
|
||||
lastName: c.lastName || '',
|
||||
name: childName(c),
|
||||
dateOfBirth: c.dateOfBirth,
|
||||
age: ageOn(c.dateOfBirth, on),
|
||||
gender: c.gender || null,
|
||||
allergies,
|
||||
hasMedical: !!medicalNotes,
|
||||
hasCustody: !!c.custodyNotes,
|
||||
...(notes ? { medicalNotes, custodyNotes: open(c.custodyNotes) } : {}),
|
||||
lastCheckInAt: c.lastCheckInAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Families ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const FAMILY_INCLUDE = {
|
||||
members: { orderBy: { createdAt: 'asc' }, include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } } },
|
||||
children: { orderBy: [{ dateOfBirth: 'asc' }, { firstName: 'asc' }] },
|
||||
adults: { orderBy: { createdAt: 'asc' } },
|
||||
};
|
||||
|
||||
const realEmail = e => (e && !/@(guest\.local|deleted\.invalid|deleted\.local)$/.test(e) ? e : null);
|
||||
|
||||
function familyView(f, { notes = true, on = new Date() } = {}) {
|
||||
return {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
parents: f.members.map(m => ({ userId: m.user.id, name: m.user.name, phone: m.user.phoneNumber || null, email: realEmail(m.user.email) })),
|
||||
children: f.children.map(c => childView(c, { notes, on })),
|
||||
adults: f.adults.map(a => ({ id: a.id, name: a.name, phone: a.phone || null, relationship: a.relationship || null })),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadFamily(id, db = prisma) {
|
||||
const f = await db.family.findUnique({ where: { id: String(id || '') }, include: FAMILY_INCLUDE });
|
||||
if (!f) throw kidsError('Family not found', 404);
|
||||
return f;
|
||||
}
|
||||
|
||||
/** The signed-in person's family, or null. */
|
||||
async function familyOfUser(userId, db = prisma) {
|
||||
const m = await db.familyMember.findUnique({ where: { userId }, select: { familyId: true } });
|
||||
return m ? db.family.findUnique({ where: { id: m.familyId }, include: FAMILY_INCLUDE }) : null;
|
||||
}
|
||||
|
||||
/** Create a family with `userId` as its first parent (a person is in one family at most). */
|
||||
async function createFamily({ name, userId }, db = prisma) {
|
||||
if (await db.familyMember.findUnique({ where: { userId } })) throw kidsError('This person is already a parent in a family.', 409);
|
||||
const clean = String(name || '').trim().slice(0, 100);
|
||||
if (!clean) throw kidsError('Enter a name for the family (e.g. "The Smith family").');
|
||||
const f = await db.family.create({ data: { name: clean, qrToken: crypto.randomBytes(16).toString('hex'), members: { create: { userId } } } });
|
||||
return loadFamily(f.id, db);
|
||||
}
|
||||
|
||||
function cleanChild(input, existing = null) {
|
||||
const data = {};
|
||||
const b = input || {};
|
||||
if (b.firstName !== undefined || !existing) {
|
||||
const first = String(b.firstName || '').trim().slice(0, 60);
|
||||
if (!first) throw kidsError('Enter the child\'s first name.');
|
||||
data.firstName = first;
|
||||
}
|
||||
if (b.lastName !== undefined) data.lastName = String(b.lastName || '').trim().slice(0, 60) || null;
|
||||
if (b.dateOfBirth !== undefined) {
|
||||
if (!b.dateOfBirth) data.dateOfBirth = null;
|
||||
else {
|
||||
const d = new Date(b.dateOfBirth);
|
||||
if (Number.isNaN(d.getTime()) || d > new Date() || ageOn(d) > 25) throw kidsError('Enter a valid date of birth.');
|
||||
data.dateOfBirth = d;
|
||||
}
|
||||
}
|
||||
if (b.gender !== undefined) data.gender = ['male', 'female'].includes(b.gender) ? b.gender : null;
|
||||
if (b.allergies !== undefined) data.allergies = seal(b.allergies);
|
||||
if (b.medicalNotes !== undefined) data.medicalNotes = seal(b.medicalNotes);
|
||||
if (b.custodyNotes !== undefined) data.custodyNotes = seal(b.custodyNotes);
|
||||
return data;
|
||||
}
|
||||
|
||||
function cleanAdult(input) {
|
||||
const name = String(input?.name || '').trim().slice(0, 100);
|
||||
if (!name) throw kidsError('Enter the adult\'s name.');
|
||||
const { normalizeZAPhone } = require('./whatsapp');
|
||||
const rawPhone = String(input?.phone || '').trim();
|
||||
const phone = rawPhone ? (normalizeZAPhone(rawPhone) || rawPhone.replace(/[^\d+]/g, '')).slice(0, 20) : null;
|
||||
return { name, phone, relationship: String(input?.relationship || '').trim().slice(0, 60) || null };
|
||||
}
|
||||
|
||||
/** Whether custody notes change between the stored child and the cleaned update. */
|
||||
function custodyChanged(existing, data) {
|
||||
return data.custodyNotes !== undefined && open(existing?.custodyNotes) !== open(data.custodyNotes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Families matching a search: family name, a child's name, a parent's name, email or phone.
|
||||
* Children's names and allergies are encrypted only in part (allergies), so names search fine.
|
||||
*/
|
||||
async function searchFamilies(q, db = prisma, { limit = 20 } = {}) {
|
||||
const term = String(q || '').trim();
|
||||
if (term.length < 2) return [];
|
||||
const digits = term.replace(/\D/g, '');
|
||||
const phoneTerms = digits.length >= 6 ? [digits, digits.startsWith('0') ? '27' + digits.slice(1) : null, digits.startsWith('27') ? '0' + digits.slice(2) : null].filter(Boolean) : [];
|
||||
const ci = { contains: term, mode: 'insensitive' };
|
||||
const rows = await db.family.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ name: ci },
|
||||
{ children: { some: { OR: [{ firstName: ci }, { lastName: ci }] } } },
|
||||
{ members: { some: { user: { OR: [{ name: ci }, { email: ci }, ...phoneTerms.map(p => ({ phoneNumber: { contains: p } }))] } } } },
|
||||
...phoneTerms.map(p => ({ adults: { some: { phone: { contains: p } } } })),
|
||||
],
|
||||
},
|
||||
include: FAMILY_INCLUDE,
|
||||
orderBy: { name: 'asc' },
|
||||
take: limit,
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── Events ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadKidsEvent(eventId, db = prisma) {
|
||||
const event = await db.event.findUnique({ where: { id: String(eventId || '') }, include: { sessions: { orderBy: { startsAt: 'asc' } } } });
|
||||
if (!event) throw kidsError('Event not found', 404);
|
||||
if (!event.kidsCheckIn || !event.kidsOptionId || !event.kidsRoomSetId) throw kidsError('Kids check-in isn\'t set up for this event.', 400, 'KIDS_NOT_SET_UP');
|
||||
return event;
|
||||
}
|
||||
|
||||
async function roomsFor(event, db = prisma) {
|
||||
return db.allocationGroup.findMany({ where: { setId: event.kidsRoomSetId }, orderBy: { order: 'asc' } });
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's kids settings, with what the supervisor can choose from: main-ticket options
|
||||
* that cover the whole event, and allocation sets.
|
||||
*/
|
||||
async function getSettings(eventId, db = prisma) {
|
||||
const event = await db.event.findUnique({
|
||||
where: { id: String(eventId || '') },
|
||||
select: { id: true, title: true, scheduleType: true, kidsCheckIn: true, kidsOptionId: true, kidsRoomSetId: true },
|
||||
});
|
||||
if (!event) throw kidsError('Event not found', 404);
|
||||
const [options, sets] = await Promise.all([
|
||||
db.eventOption.findMany({ where: { eventId: event.id, isMainTicket: true, sessionMode: 'all' }, select: { id: true, name: true, price: true }, orderBy: { name: 'asc' } }),
|
||||
db.allocationSet.findMany({ where: { eventId: event.id }, select: { id: true, name: true, groups: { orderBy: { order: 'asc' }, select: { id: true, name: true, capacity: true, minAge: true, maxAge: true, ratio: true, order: true } } }, orderBy: { order: 'asc' } }),
|
||||
]);
|
||||
return { event, options, sets };
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an event's kids settings. `optionId: 'new'` adds a free "Kids check-in" main ticket;
|
||||
* `roomSetId: 'new'` adds a "Kids rooms" set. Switching on needs both.
|
||||
*/
|
||||
async function saveSettings(eventId, { enabled, optionId, roomSetId }, db = prisma) {
|
||||
const { event } = await getSettings(eventId, db);
|
||||
const data = {};
|
||||
if (optionId !== undefined) {
|
||||
if (optionId === 'new') {
|
||||
const opt = await db.eventOption.create({ data: { eventId: event.id, name: 'Kids check-in', price: 0, isMainTicket: true, sessionMode: 'all' } });
|
||||
data.kidsOptionId = opt.id;
|
||||
} else if (optionId) {
|
||||
const opt = await db.eventOption.findFirst({ where: { id: String(optionId), eventId: event.id, isMainTicket: true, sessionMode: 'all' } });
|
||||
if (!opt) throw kidsError('Choose a main ticket of this event that covers the whole event.');
|
||||
data.kidsOptionId = opt.id;
|
||||
} else data.kidsOptionId = null;
|
||||
}
|
||||
if (roomSetId !== undefined) {
|
||||
if (roomSetId === 'new') {
|
||||
const order = await db.allocationSet.count({ where: { eventId: event.id } });
|
||||
const set = await db.allocationSet.create({ data: { eventId: event.id, name: 'Kids rooms', order } });
|
||||
data.kidsRoomSetId = set.id;
|
||||
} else if (roomSetId) {
|
||||
const set = await db.allocationSet.findFirst({ where: { id: String(roomSetId), eventId: event.id } });
|
||||
if (!set) throw kidsError('Choose one of this event\'s allocation sets.');
|
||||
data.kidsRoomSetId = set.id;
|
||||
} else data.kidsRoomSetId = null;
|
||||
}
|
||||
if (enabled !== undefined) data.kidsCheckIn = !!enabled;
|
||||
const next = { ...event, ...data };
|
||||
if (next.kidsCheckIn && (!next.kidsOptionId || !next.kidsRoomSetId)) throw kidsError('Choose the kids ticket and the rooms before switching kids check-in on.');
|
||||
await db.event.update({ where: { id: event.id }, data });
|
||||
return getSettings(event.id, db);
|
||||
}
|
||||
|
||||
// ─── Codes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const isUniqueClash = e => e?.code === 'P2002';
|
||||
|
||||
/** The family's pickup code for the occasion, made if it has none yet. */
|
||||
async function codeForFamily(eventId, occasion, familyId, db = prisma) {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const existing = await db.kidsPickupCode.findUnique({ where: { eventId_occasion_familyId: { eventId, occasion, familyId } } });
|
||||
if (existing) return existing;
|
||||
try {
|
||||
return await db.kidsPickupCode.create({ data: { eventId, occasion, familyId, code: randomCode() } });
|
||||
} catch (e) {
|
||||
if (!isUniqueClash(e)) throw e;
|
||||
// Either the code is taken (try another) or the family got one meanwhile (found above).
|
||||
}
|
||||
}
|
||||
throw kidsError('Could not make a pickup code — try again.', 503);
|
||||
}
|
||||
|
||||
/** Codes held for an offline desk: tops its unused ones up to `count`. */
|
||||
async function reserveCodes(eventId, occasion, deviceId, count = OFFLINE_CODES, db = prisma) {
|
||||
const have = await db.kidsPickupCode.findMany({ where: { eventId, occasion, deviceId, familyId: null }, select: { code: true } });
|
||||
let need = Math.max(0, count - have.length);
|
||||
for (let tries = 0; need > 0 && tries < count * 5; tries++) {
|
||||
try {
|
||||
await db.kidsPickupCode.create({ data: { eventId, occasion, deviceId, code: randomCode() } });
|
||||
need--;
|
||||
} catch (e) {
|
||||
if (!isUniqueClash(e)) throw e;
|
||||
}
|
||||
}
|
||||
const rows = await db.kidsPickupCode.findMany({ where: { eventId, occasion, deviceId, familyId: null }, select: { code: true }, orderBy: { createdAt: 'asc' } });
|
||||
return rows.map(r => r.code);
|
||||
}
|
||||
|
||||
// ─── Attendance ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The child's ticket for the event: the one from an earlier check-in, or a new free place on
|
||||
* the kids option — on the parent's kids registration for this event (made the first time,
|
||||
* separate from any registration they paid for themselves).
|
||||
*/
|
||||
async function ticketForChild(tx, event, parentUserId, child) {
|
||||
const earlier = await tx.kidsCheckIn.findFirst({ where: { eventId: event.id, childId: child.id, ticketId: { not: null } }, select: { ticketId: true, attendeeId: true } });
|
||||
if (earlier) {
|
||||
const ticket = await tx.ticket.findUnique({ where: { id: earlier.ticketId }, select: { id: true } });
|
||||
if (ticket) return { ticketId: ticket.id, attendeeId: earlier.attendeeId };
|
||||
}
|
||||
let registration = await tx.registration.findFirst({
|
||||
where: { userId: parentUserId, eventId: event.id, status: { not: 'cancelled' }, registrationOptions: { some: { eventOptionId: event.kidsOptionId, sessionId: null } } },
|
||||
include: { registrationOptions: { where: { eventOptionId: event.kidsOptionId, sessionId: null } } },
|
||||
});
|
||||
if (!registration) {
|
||||
registration = await tx.registration.create({ data: { userId: parentUserId, eventId: event.id, status: 'paid' }, include: { registrationOptions: true } });
|
||||
}
|
||||
let line = registration.registrationOptions[0];
|
||||
if (line) line = await tx.registrationOption.update({ where: { id: line.id }, data: { quantity: { increment: 1 } } });
|
||||
else line = await tx.registrationOption.create({ data: { registrationId: registration.id, eventOptionId: event.kidsOptionId, quantity: 1, priceSnapshot: 0 } });
|
||||
await tx.registrationOptionTranche.create({ data: { registrationOptionId: line.id, quantity: 1, priceSnapshot: 0 } });
|
||||
const order = await tx.attendee.count({ where: { registrationId: registration.id } });
|
||||
const parent = await tx.user.findUnique({ where: { id: parentUserId }, select: { name: true, email: true, phoneNumber: true } });
|
||||
const attendee = await tx.attendee.create({
|
||||
data: {
|
||||
registrationId: registration.id, registrationOptionId: line.id, eventOptionId: event.kidsOptionId,
|
||||
name: childName(child), dateOfBirth: child.dateOfBirth, gender: child.gender,
|
||||
parentName: parent?.name || null, parentEmail: realEmail(parent?.email), parentPhone: parent?.phoneNumber || null, order,
|
||||
},
|
||||
});
|
||||
const ticket = await tx.ticket.create({
|
||||
data: { id: uuidv4(), qrCode: uuidv4(), registrationOptionId: line.id, userId: parentUserId, eventId: event.id, quantity: 1, attendeeId: attendee.id, emailSent: true },
|
||||
});
|
||||
return { ticketId: ticket.id, attendeeId: attendee.id };
|
||||
}
|
||||
|
||||
// ─── Check-in and check-out ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check children of a family in. `children` = [{ childId, roomId? }] (room suggested from the
|
||||
* age when not given). Children already in for this occasion stay as they are (their room can
|
||||
* change); children checked out earlier today are checked in again. Offline syncs pass the
|
||||
* code row, `clientId`s and the time it happened.
|
||||
* Returns { code, family, checkIns, already }.
|
||||
*/
|
||||
async function checkIn({ eventId, sessionId = null, familyId, children, staffUserId, now = new Date(), occasionOverride = null, codeRow = null, offline = false }, db = prisma) {
|
||||
const event = await loadKidsEvent(eventId, db);
|
||||
const { occasion, sessionId: sid } = occasionOverride || occasionFor(event, event.sessions, sessionId, now);
|
||||
const family = await loadFamily(familyId, db);
|
||||
const parent = family.members[0];
|
||||
if (!parent) throw kidsError('This family has no parent account — add a parent first.');
|
||||
if (!Array.isArray(children) || !children.length) throw kidsError('Choose the children to check in.');
|
||||
const rooms = await roomsFor(event, db);
|
||||
if (!rooms.length) throw kidsError('This event has no kids rooms yet — a supervisor adds them under Kids check-in → Setup.');
|
||||
const counts = await roomCounts(event.id, occasion, db);
|
||||
|
||||
const picks = children.map(p => {
|
||||
const child = family.children.find(c => c.id === p?.childId);
|
||||
if (!child) throw kidsError('That child isn\'t in this family.', 404);
|
||||
let room = p.roomId ? rooms.find(r => r.id === p.roomId) : null;
|
||||
if (p.roomId && !room) throw kidsError('That room isn\'t one of this event\'s kids rooms.');
|
||||
if (!room) room = suggestRoom(rooms, ageOn(child.dateOfBirth, now), counts) || rooms[0];
|
||||
counts[room.id] = (counts[room.id] || 0) + 1;
|
||||
return { child, room, clientId: p.clientId || null };
|
||||
});
|
||||
|
||||
const code = codeRow || await codeForFamily(event.id, occasion, family.id, db);
|
||||
const at = new Date(now);
|
||||
const result = [];
|
||||
const already = [];
|
||||
await db.$transaction(async tx => {
|
||||
for (const { child, room, clientId } of picks) {
|
||||
const existing = await tx.kidsCheckIn.findUnique({ where: { eventId_occasion_childId: { eventId: event.id, occasion, childId: child.id } } });
|
||||
if (existing && !existing.checkedOutAt) {
|
||||
if (existing.roomId !== room.id && !offline) await tx.kidsCheckIn.update({ where: { id: existing.id }, data: { roomId: room.id } });
|
||||
already.push(existing.id);
|
||||
result.push(existing.id);
|
||||
continue;
|
||||
}
|
||||
if (existing) {
|
||||
await tx.kidsCheckIn.update({
|
||||
where: { id: existing.id },
|
||||
data: { roomId: room.id, pickupCodeId: code.id, checkedInAt: at, checkedInById: staffUserId, checkedOutAt: null, checkedOutById: null, collectedBy: null, overrideReason: null },
|
||||
});
|
||||
result.push(existing.id);
|
||||
continue;
|
||||
}
|
||||
const { ticketId, attendeeId } = await ticketForChild(tx, event, parent.userId, child);
|
||||
await tx.ticketUsage.create({ data: { ticketId, scannedById: staffUserId, scannedAt: at, sessionId: sid, offline, syncedAt: offline ? new Date() : null } });
|
||||
if (event.scheduleType !== 'sessions') await tx.ticket.update({ where: { id: ticketId }, data: { isUsed: true } });
|
||||
const row = await tx.kidsCheckIn.create({
|
||||
data: {
|
||||
eventId: event.id, occasion, sessionId: sid, childId: child.id, childName: childName(child), roomId: room.id,
|
||||
pickupCodeId: code.id, attendeeId, ticketId, checkedInAt: at, checkedInById: staffUserId, clientId, offline,
|
||||
},
|
||||
});
|
||||
result.push(row.id);
|
||||
}
|
||||
await tx.child.updateMany({ where: { id: { in: picks.map(p => p.child.id) } }, data: { lastCheckInAt: at } });
|
||||
});
|
||||
|
||||
const checkIns = await db.kidsCheckIn.findMany({ where: { id: { in: result } }, include: { room: { select: { id: true, name: true } }, child: true } });
|
||||
return { event, occasion, sessionId: sid, code: code.code, family, checkIns, already };
|
||||
}
|
||||
|
||||
/** Children in per room for an occasion: { roomId: n }. */
|
||||
async function roomCounts(eventId, occasion, db = prisma) {
|
||||
const rows = await db.kidsCheckIn.groupBy({ by: ['roomId'], where: { eventId, occasion, checkedOutAt: null }, _count: { _all: true } });
|
||||
return Object.fromEntries(rows.filter(r => r.roomId).map(r => [r.roomId, r._count._all]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check out with a pickup code. `checkInIds` limits it to some of the children on the code
|
||||
* (default all still in). A code that matches nothing raises a wrong-code alert for the room
|
||||
* and throws WRONG_CODE.
|
||||
*/
|
||||
async function checkOut({ eventId, sessionId = null, code: rawCode, checkInIds = null, collectedBy, roomId = null, staffUserId, now = new Date() }, db = prisma) {
|
||||
const event = await loadKidsEvent(eventId, db);
|
||||
const { occasion } = occasionFor(event, event.sessions, sessionId, now);
|
||||
const code = normaliseCode(rawCode);
|
||||
const row = code ? await db.kidsPickupCode.findUnique({ where: { eventId_occasion_code: { eventId: event.id, occasion, code } } }) : null;
|
||||
const open = row ? await db.kidsCheckIn.findMany({ where: { pickupCodeId: row.id, checkedOutAt: null, ...(checkInIds?.length ? { id: { in: checkInIds.map(String) } } : {}) } }) : [];
|
||||
if (!open.length) {
|
||||
const typed = String(rawCode || '').trim().slice(0, 20);
|
||||
const message = row ? `Code ${code} has no children still checked in.` : `Wrong pickup code entered: "${typed}".`;
|
||||
if (!row) await db.kidsAlert.create({ data: { eventId: event.id, occasion, kind: 'wrong_code', roomId: roomId || null, message, createdById: staffUserId } });
|
||||
throw kidsError(row ? message : 'That pickup code doesn\'t match any child checked in. The room leader has been alerted.', 404, row ? 'NOTHING_TO_COLLECT' : 'WRONG_CODE');
|
||||
}
|
||||
const who = String(collectedBy || '').trim().slice(0, 100) || null;
|
||||
await db.kidsCheckIn.updateMany({ where: { id: { in: open.map(c => c.id) } }, data: { checkedOutAt: new Date(now), checkedOutById: staffUserId, collectedBy: who } });
|
||||
return { event, occasion, checkIns: await db.kidsCheckIn.findMany({ where: { id: { in: open.map(c => c.id) } }, include: { room: { select: { id: true, name: true } } } }) };
|
||||
}
|
||||
|
||||
/** Check one child out without the code (lost phone, etc.). The reason is required and logged. */
|
||||
async function overrideCheckOut({ checkInId, reason, collectedBy, staffUserId, now = new Date() }, db = prisma) {
|
||||
const ci = await db.kidsCheckIn.findUnique({ where: { id: String(checkInId || '') } });
|
||||
if (!ci) throw kidsError('Check-in not found', 404);
|
||||
if (ci.checkedOutAt) throw kidsError(`${ci.childName} has already been collected.`);
|
||||
const why = String(reason || '').trim().slice(0, 300);
|
||||
if (why.length < 5) throw kidsError('Give the reason for checking out without the code.');
|
||||
const who = String(collectedBy || '').trim().slice(0, 100);
|
||||
if (!who) throw kidsError('Enter who collected the child.');
|
||||
const updated = await db.kidsCheckIn.update({ where: { id: ci.id }, data: { checkedOutAt: new Date(now), checkedOutById: staffUserId, collectedBy: who, overrideReason: why } });
|
||||
await db.kidsAlert.create({ data: { eventId: ci.eventId, occasion: ci.occasion, kind: 'override', roomId: ci.roomId, checkInId: ci.id, message: `${ci.childName} checked out without the code by ${who}: ${why}`, createdById: staffUserId, resolvedAt: new Date(now) } });
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ─── Messages ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function listNames(names) {
|
||||
return names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
||||
}
|
||||
|
||||
function frontendUrl() {
|
||||
return String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/** The check-in message: who, where and the code. Never medical or custody details. */
|
||||
// `forParent` adds where to find the QR code (authorised adults have no account to look in).
|
||||
function checkInMessage({ eventTitle, code, children, forParent = true }) {
|
||||
const names = children.map(c => c.name);
|
||||
const rooms = [...new Set(children.map(c => c.room).filter(Boolean))];
|
||||
const how = forParent ? `Show this code — or the QR code in My account → Kids (${frontendUrl()}/dashboard/user/kids) —` : 'Show this code';
|
||||
return `${listNames(names)} ${names.length === 1 ? 'is' : 'are'} checked in at ${eventTitle}${rooms.length ? ` (${rooms.join(', ')})` : ''}. Pickup code: ${code}. ${how} to collect ${names.length === 1 ? 'them' : 'them all'}.`;
|
||||
}
|
||||
|
||||
/** Send a text to a parent the way they like it (WhatsApp, email, or whichever works). */
|
||||
async function messageParent(user, { subject, heading, text }) {
|
||||
const { canWhatsApp, shouldEmail, waTextAny } = require('./notify');
|
||||
const email = realEmail(user.email);
|
||||
const sent = [];
|
||||
if (canWhatsApp(user) || (!email && user.phoneNumber)) {
|
||||
await waTextAny(user, text);
|
||||
sent.push('whatsapp');
|
||||
}
|
||||
if (email && (shouldEmail(user) || !sent.length)) {
|
||||
const { sendMail, buildPrivacyEmail } = require('./email');
|
||||
await sendMail({ to: email, subject, ...buildPrivacyEmail({ name: user.name, heading, paragraphs: [text] }) }).catch(() => {});
|
||||
sent.push('email');
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** Tell the family's parents (and an authorised adult, if chosen) the code. Fire-and-forget. */
|
||||
async function sendCheckInMessages({ event, family, code, checkIns, adultId = null }) {
|
||||
const children = checkIns.map(ci => ({ name: ci.child?.firstName || ci.childName, room: ci.room?.name || null }));
|
||||
const text = checkInMessage({ eventTitle: event.title, code, children });
|
||||
for (const m of family.members) {
|
||||
await messageParent(m.user, { subject: `Pickup code ${code} — ${event.title}`, heading: `Pickup code ${code}`, text }).catch(() => {});
|
||||
}
|
||||
const adult = adultId ? family.adults.find(a => a.id === adultId) : null;
|
||||
if (adult?.phone) {
|
||||
const { sendText } = require('./whatsapp');
|
||||
await sendText(adult.phone, `${family.name}: ${checkInMessage({ eventTitle: event.title, code, children, forParent: false })}`).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Page a parent: "Please come to the Toddlers room". Logged as an alert the parent sees too. */
|
||||
async function pageParent({ checkInId, message, staffUserId }, db = prisma) {
|
||||
const ci = await db.kidsCheckIn.findUnique({ where: { id: String(checkInId || '') }, include: { room: true, event: { select: { title: true } }, pickupCode: { select: { familyId: true } } } });
|
||||
if (!ci) throw kidsError('Check-in not found', 404);
|
||||
if (ci.checkedOutAt) throw kidsError(`${ci.childName} has already been collected.`);
|
||||
const family = ci.pickupCode.familyId ? await loadFamily(ci.pickupCode.familyId, db) : null;
|
||||
if (!family?.members.length) throw kidsError('There\'s no parent to contact for this child.');
|
||||
const extra = String(message || '').trim().slice(0, 300);
|
||||
const first = ci.childName.split(' ')[0];
|
||||
const text = `Please come to the ${ci.room?.name || 'kids'} room for ${first} (${ci.event.title}).${extra ? ` ${extra}` : ''}`;
|
||||
const channels = new Set();
|
||||
for (const m of family.members) {
|
||||
for (const c of await messageParent(m.user, { subject: `Please come to the ${ci.room?.name || 'kids'} room`, heading: `Please come to the ${ci.room?.name || 'kids'} room`, text }).catch(() => [])) channels.add(c);
|
||||
}
|
||||
await db.kidsAlert.create({ data: { eventId: ci.eventId, occasion: ci.occasion, kind: 'page_parent', roomId: ci.roomId, checkInId: ci.id, message: text, createdById: staffUserId } });
|
||||
return { sent: [...channels], text };
|
||||
}
|
||||
|
||||
// ─── Boards ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Everything the desk and room screens show for an occasion: rooms with counts and leaders on
|
||||
* duty, the children checked in (with allergies, medical and custody notes), open alerts.
|
||||
*/
|
||||
async function board({ eventId, sessionId = null, now = new Date() }, db = prisma) {
|
||||
const event = await loadKidsEvent(eventId, db);
|
||||
const { occasion, sessionId: sid } = occasionFor(event, event.sessions, sessionId, now);
|
||||
const [rooms, checkIns, duties, alerts, rostered] = await Promise.all([
|
||||
roomsFor(event, db),
|
||||
db.kidsCheckIn.findMany({
|
||||
where: { eventId: event.id, occasion },
|
||||
include: {
|
||||
child: true,
|
||||
pickupCode: { select: { code: true, familyId: true } },
|
||||
checkedInBy: { select: { name: true } },
|
||||
checkedOutBy: { select: { name: true } },
|
||||
},
|
||||
orderBy: { checkedInAt: 'asc' },
|
||||
}),
|
||||
db.kidsRoomDuty.findMany({ where: { occasion, endedAt: null, room: { setId: event.kidsRoomSetId } }, include: { user: { select: { id: true, name: true } } } }),
|
||||
db.kidsAlert.findMany({ where: { eventId: event.id, occasion }, orderBy: { createdAt: 'desc' }, take: 50, include: { createdBy: { select: { name: true } } } }),
|
||||
// Volunteer rosters (1.46.0): who is rostered in each room. They count as leaders on duty
|
||||
// once they check in (a KidsRoomDuty with fromRoster), like "I'm on duty".
|
||||
require('./volunteers').rosteredForRooms(event, occasion, db),
|
||||
]);
|
||||
const familyIds = [...new Set(checkIns.map(c => c.pickupCode.familyId).filter(Boolean))];
|
||||
const families = familyIds.length ? await db.family.findMany({ where: { id: { in: familyIds } }, include: FAMILY_INCLUDE }) : [];
|
||||
const famById = Object.fromEntries(families.map(f => [f.id, f]));
|
||||
|
||||
const inByRoom = {};
|
||||
for (const c of checkIns) if (!c.checkedOutAt && c.roomId) inByRoom[c.roomId] = (inByRoom[c.roomId] || 0) + 1;
|
||||
const sessionLabel = sid ? sessionsUtil.sessionLabel(event.sessions.find(s => s.id === sid), event.sessions) : null;
|
||||
|
||||
return {
|
||||
event: { id: event.id, title: event.title, scheduleType: event.scheduleType },
|
||||
occasion,
|
||||
sessionId: sid,
|
||||
sessionLabel,
|
||||
rooms: rooms.map(r => {
|
||||
const leaders = duties.filter(d => d.roomId === r.id).map(d => d.user);
|
||||
return { id: r.id, name: r.name, minAge: r.minAge, maxAge: r.maxAge, capacity: r.capacity, ratio: r.ratio, order: r.order, leaderNames: leaders.map(l => l.name), leaderIds: leaders.map(l => l.id), rostered: rostered[r.id] || [], ...roomStatus(r, inByRoom[r.id] || 0, leaders.length) };
|
||||
}),
|
||||
checkIns: checkIns.map(c => {
|
||||
const fam = famById[c.pickupCode.familyId];
|
||||
const child = c.child ? childView(c.child, { on: now }) : null;
|
||||
return {
|
||||
id: c.id,
|
||||
childId: c.childId,
|
||||
name: c.childName,
|
||||
firstName: child?.firstName || c.childName.split(' ')[0],
|
||||
age: child?.age ?? null,
|
||||
roomId: c.roomId,
|
||||
code: c.pickupCode.code,
|
||||
checkedInAt: c.checkedInAt,
|
||||
checkedInBy: c.checkedInBy?.name || null,
|
||||
checkedOutAt: c.checkedOutAt,
|
||||
checkedOutBy: c.checkedOutBy?.name || null,
|
||||
collectedBy: c.collectedBy,
|
||||
override: !!c.overrideReason,
|
||||
offline: c.offline,
|
||||
allergies: child?.allergies || '',
|
||||
medicalNotes: child?.medicalNotes || '',
|
||||
custodyNotes: child?.custodyNotes || '',
|
||||
sticker: stickerAlert(child?.allergies, child?.medicalNotes),
|
||||
family: fam ? { id: fam.id, name: fam.name, parents: familyView(fam, { notes: false }).parents, adults: familyView(fam, { notes: false }).adults } : null,
|
||||
};
|
||||
}),
|
||||
alerts: alerts.map(a => ({ id: a.id, kind: a.kind, roomId: a.roomId, checkInId: a.checkInId, message: a.message, by: a.createdBy?.name || null, at: a.createdAt, resolvedAt: a.resolvedAt })),
|
||||
};
|
||||
}
|
||||
|
||||
/** "I'm on duty" / "I'm off" in a room for the occasion. */
|
||||
async function setDuty({ eventId, sessionId = null, roomId, userId, on, now = new Date() }, db = prisma) {
|
||||
const event = await loadKidsEvent(eventId, db);
|
||||
const { occasion } = occasionFor(event, event.sessions, sessionId, now);
|
||||
const room = await db.allocationGroup.findFirst({ where: { id: String(roomId || ''), setId: event.kidsRoomSetId } });
|
||||
if (!room) throw kidsError('That room isn\'t one of this event\'s kids rooms.', 404);
|
||||
// One room at a time: going on duty somewhere ends duty elsewhere for this occasion.
|
||||
await db.kidsRoomDuty.updateMany({ where: { userId, occasion, endedAt: null, ...(on ? {} : { roomId: room.id }) }, data: { endedAt: new Date(now) } });
|
||||
if (on) await db.kidsRoomDuty.create({ data: { roomId: room.id, occasion, userId } });
|
||||
return { roomId: room.id, on: !!on };
|
||||
}
|
||||
|
||||
/** Move a child to another room. */
|
||||
async function moveRoom({ checkInId, roomId }, db = prisma) {
|
||||
const ci = await db.kidsCheckIn.findUnique({ where: { id: String(checkInId || '') }, include: { event: { select: { kidsRoomSetId: true } } } });
|
||||
if (!ci) throw kidsError('Check-in not found', 404);
|
||||
const room = await db.allocationGroup.findFirst({ where: { id: String(roomId || ''), setId: ci.event.kidsRoomSetId } });
|
||||
if (!room) throw kidsError('That room isn\'t one of this event\'s kids rooms.', 404);
|
||||
return db.kidsCheckIn.update({ where: { id: ci.id }, data: { roomId: room.id } });
|
||||
}
|
||||
|
||||
// ─── The parent's view ────────────────────────────────────────────────────────
|
||||
|
||||
/** The parent's live codes: children checked in now (not collected), grouped by code, plus pages from today. */
|
||||
async function activeForFamily(familyId, now = new Date(), db = prisma) {
|
||||
const since = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const checkIns = await db.kidsCheckIn.findMany({
|
||||
where: { pickupCode: { familyId }, checkedOutAt: null, checkedInAt: { gte: since } },
|
||||
include: { pickupCode: { select: { code: true } }, room: { select: { name: true } }, event: { select: { id: true, title: true } } },
|
||||
orderBy: { checkedInAt: 'asc' },
|
||||
});
|
||||
const byCode = new Map();
|
||||
for (const c of checkIns) {
|
||||
const key = `${c.eventId}:${c.pickupCode.code}`;
|
||||
if (!byCode.has(key)) byCode.set(key, { code: c.pickupCode.code, qr: QR_PREFIX + c.pickupCode.code, event: c.event, children: [] });
|
||||
byCode.get(key).children.push({ name: c.childName, room: c.room?.name || null, checkedInAt: c.checkedInAt });
|
||||
}
|
||||
const pages = checkIns.length ? await db.kidsAlert.findMany({
|
||||
where: { kind: 'page_parent', checkInId: { in: checkIns.map(c => c.id) }, createdAt: { gte: since } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true, message: true, createdAt: true },
|
||||
}) : [];
|
||||
return { codes: [...byCode.values()], pages };
|
||||
}
|
||||
|
||||
// ─── Offline desks ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* What a desk downloads to keep checking in without a connection: the families (with
|
||||
* children), the rooms, who is already in, and codes reserved for this device.
|
||||
*/
|
||||
async function offlineSnapshot({ eventId, sessionId = null, deviceId, now = new Date() }, db = prisma) {
|
||||
const dev = String(deviceId || '').trim().slice(0, 64);
|
||||
if (!dev) throw kidsError('deviceId is required');
|
||||
const b = await board({ eventId, sessionId, now }, db);
|
||||
const event = await loadKidsEvent(eventId, db);
|
||||
const families = await db.family.findMany({ where: { children: { some: {} }, members: { some: {} } }, include: FAMILY_INCLUDE, orderBy: { name: 'asc' }, take: 3000 });
|
||||
const codes = await reserveCodes(event.id, b.occasion, dev, OFFLINE_CODES, db);
|
||||
return {
|
||||
occasion: b.occasion,
|
||||
sessionId: b.sessionId,
|
||||
sessionLabel: b.sessionLabel,
|
||||
event: b.event,
|
||||
rooms: b.rooms,
|
||||
checkIns: b.checkIns,
|
||||
families: families.map(f => familyView(f, { on: now })),
|
||||
codes,
|
||||
downloadedAt: new Date(now).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync an offline desk. `checkIns` = [{ clientId, familyId, childId, roomId, code, at }] made
|
||||
* with the device's reserved codes; `checkOuts` = [{ clientId, collectedBy, at }] for children
|
||||
* checked in on this device. Idempotent (clientId). Returns { checkedIn, checkedOut, conflicts }.
|
||||
*/
|
||||
async function syncOffline({ eventId, deviceId, occasion, sessionId = null, checkIns = [], checkOuts = [], staffUserId }, db = prisma) {
|
||||
const event = await loadKidsEvent(eventId, db);
|
||||
const dev = String(deviceId || '').trim().slice(0, 64);
|
||||
const occ = String(occasion || '');
|
||||
const validOccasion = event.scheduleType === 'sessions' ? event.sessions.some(s => s.id === occ) : /^\d{4}-\d{2}-\d{2}$/.test(occ);
|
||||
if (!dev || !validOccasion) throw kidsError('This offline list isn\'t for this event.');
|
||||
const sid = event.scheduleType === 'sessions' ? occ : null;
|
||||
const out = { checkedIn: 0, checkedOut: 0, conflicts: [] };
|
||||
|
||||
for (const item of (Array.isArray(checkIns) ? checkIns : []).slice(0, 500)) {
|
||||
const clientId = String(item?.clientId || '').slice(0, 64);
|
||||
if (!clientId) continue;
|
||||
if (await db.kidsCheckIn.findUnique({ where: { clientId } })) continue;
|
||||
const at = item.at && !Number.isNaN(new Date(item.at).getTime()) ? new Date(item.at) : new Date();
|
||||
try {
|
||||
const code = normaliseCode(item.code);
|
||||
let row = code ? await db.kidsPickupCode.findUnique({ where: { eventId_occasion_code: { eventId: event.id, occasion: occ, code } } }) : null;
|
||||
if (!row || (row.deviceId !== dev && row.familyId !== item.familyId)) throw kidsError(`Code ${item.code} wasn't reserved for this device.`);
|
||||
if (!row.familyId) {
|
||||
const theirs = await db.kidsPickupCode.findUnique({ where: { eventId_occasion_familyId: { eventId: event.id, occasion: occ, familyId: String(item.familyId || '') } } });
|
||||
if (theirs) {
|
||||
out.conflicts.push({ clientId, message: `This family already had code ${theirs.code} from another desk — tell the parent their code is ${theirs.code}, not ${code}.` });
|
||||
row = theirs;
|
||||
} else {
|
||||
row = await db.kidsPickupCode.update({ where: { id: row.id }, data: { familyId: String(item.familyId || '') } });
|
||||
}
|
||||
}
|
||||
await checkIn({
|
||||
eventId: event.id, familyId: item.familyId, children: [{ childId: item.childId, roomId: item.roomId, clientId }],
|
||||
staffUserId, now: at, occasionOverride: { occasion: occ, sessionId: sid }, codeRow: row, offline: true,
|
||||
}, db);
|
||||
out.checkedIn++;
|
||||
} catch (e) {
|
||||
out.conflicts.push({ clientId, message: e.message || 'Could not sync this check-in.' });
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of (Array.isArray(checkOuts) ? checkOuts : []).slice(0, 500)) {
|
||||
const ci = item?.clientId ? await db.kidsCheckIn.findUnique({ where: { clientId: String(item.clientId) } }) : null;
|
||||
if (!ci) { out.conflicts.push({ clientId: item?.clientId || null, message: 'A check-out was for a check-in that never synced.' }); continue; }
|
||||
if (ci.checkedOutAt) continue;
|
||||
const at = item.at && !Number.isNaN(new Date(item.at).getTime()) ? new Date(item.at) : new Date();
|
||||
await db.kidsCheckIn.update({ where: { id: ci.id }, data: { checkedOutAt: at, checkedOutById: staffUserId, collectedBy: String(item.collectedBy || '').trim().slice(0, 100) || null } });
|
||||
out.checkedOut++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Privacy ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** For anonymiseUser: leave the family; a family nobody is left in is deleted with its children. */
|
||||
async function removeFromFamily(tx, userId) {
|
||||
const m = await tx.familyMember.findUnique({ where: { userId } });
|
||||
if (!m) return { familyDeleted: false };
|
||||
await tx.familyMember.delete({ where: { id: m.id } });
|
||||
const left = await tx.familyMember.count({ where: { familyId: m.familyId } });
|
||||
if (left) return { familyDeleted: false };
|
||||
await deleteFamilyData(tx, m.familyId);
|
||||
return { familyDeleted: true };
|
||||
}
|
||||
|
||||
/** Delete a family and its children; check-ins keep the attendance but lose who collected. */
|
||||
async function deleteFamilyData(tx, familyId) {
|
||||
await tx.kidsCheckIn.updateMany({ where: { pickupCode: { familyId } }, data: { collectedBy: null, overrideReason: null } });
|
||||
await tx.family.delete({ where: { id: familyId } });
|
||||
}
|
||||
|
||||
/** For "Download my data". */
|
||||
async function exportFamily(userId, db = prisma) {
|
||||
const f = await familyOfUser(userId, db);
|
||||
if (!f) return null;
|
||||
const checkIns = await db.kidsCheckIn.findMany({
|
||||
where: { pickupCode: { familyId: f.id } },
|
||||
include: { event: { select: { title: true } }, room: { select: { name: true } } },
|
||||
orderBy: { checkedInAt: 'asc' },
|
||||
});
|
||||
const v = familyView(f);
|
||||
return {
|
||||
name: v.name,
|
||||
parents: v.parents.map(p => p.name),
|
||||
children: v.children.map(c => ({ name: c.name, dateOfBirth: c.dateOfBirth, gender: c.gender, allergies: c.allergies, medicalNotes: c.medicalNotes, custodyNotes: c.custodyNotes })),
|
||||
authorisedAdults: v.adults,
|
||||
checkIns: checkIns.map(c => ({ child: c.childName, event: c.event?.title, room: c.room?.name || null, in: c.checkedInAt, out: c.checkedOutAt, collectedBy: c.collectedBy })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retention: children not checked in (or added) for `months` are deleted, then families left
|
||||
* with no children. Who collected children (and override reasons) is cleared from check-ins as
|
||||
* old; alerts that old are deleted.
|
||||
*/
|
||||
async function runKidsRetention(cutoff, db = prisma) {
|
||||
const children = await db.child.deleteMany({
|
||||
where: { OR: [{ lastCheckInAt: { lt: cutoff } }, { lastCheckInAt: null, createdAt: { lt: cutoff } }] },
|
||||
});
|
||||
const emptyFamilies = await db.family.findMany({ where: { children: { none: {} }, updatedAt: { lt: cutoff } }, select: { id: true } });
|
||||
for (const f of emptyFamilies) await db.$transaction(tx => deleteFamilyData(tx, f.id));
|
||||
const scrubbed = await db.kidsCheckIn.updateMany({ where: { checkedInAt: { lt: cutoff }, OR: [{ collectedBy: { not: null } }, { overrideReason: { not: null } }] }, data: { collectedBy: null, overrideReason: null } });
|
||||
const alerts = await db.kidsAlert.deleteMany({ where: { createdAt: { lt: cutoff } } });
|
||||
const duties = await db.kidsRoomDuty.deleteMany({ where: { startedAt: { lt: cutoff } } });
|
||||
return { children: children.count, families: emptyFamilies.length, checkIns: scrubbed.count, alerts: alerts.count + duties.count };
|
||||
}
|
||||
|
||||
async function previewKidsRetention(cutoff, db = prisma) {
|
||||
return {
|
||||
count: await db.child.count({ where: { OR: [{ lastCheckInAt: { lt: cutoff } }, { lastCheckInAt: null, createdAt: { lt: cutoff } }] } }),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CODE_ALPHABET, CODE_LENGTH, QR_PREFIX, kidsError,
|
||||
randomCode, normaliseCode, ageOn, fitsRoom, suggestRoom, roomStatus, stickerAlert, occasionFor, checkInMessage, listNames,
|
||||
childView, familyView, loadFamily, familyOfUser, createFamily, cleanChild, cleanAdult, custodyChanged, searchFamilies,
|
||||
loadKidsEvent, roomsFor, getSettings, saveSettings,
|
||||
codeForFamily, reserveCodes, checkIn, checkOut, overrideCheckOut, messageParent, sendCheckInMessages, pageParent,
|
||||
board, setDuty, moveRoom, activeForFamily, offlineSnapshot, syncOffline,
|
||||
removeFromFamily, exportFamily, runKidsRetention, previewKidsRetention,
|
||||
};
|
||||
@@ -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) {
|
||||
@@ -1506,6 +1513,7 @@ async function sendAttendeeFormLinkMessage(attendee) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getEventNotifyEmails,
|
||||
buildEventNotifyRecipientList,
|
||||
sendAttendeeFormLinkMessage,
|
||||
sendRegistrationEmails,
|
||||
|
||||
@@ -404,6 +404,7 @@ async function anonymiseUser(userId, db = prisma) {
|
||||
tokenVersion: { increment: 1 },
|
||||
newsEmail: false,
|
||||
newsWhatsApp: false,
|
||||
homeCampusId: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -454,7 +455,15 @@ async function anonymiseUser(userId, db = prisma) {
|
||||
await tx.passwordReset.deleteMany({ where: { userId } });
|
||||
await tx.twoFactorChallenge.deleteMany({ where: { userId } });
|
||||
|
||||
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length };
|
||||
// Kids check-in: leave their family; a family with no parent left is deleted with its
|
||||
// children (their check-ins stay as attendance, without who collected them).
|
||||
const { familyDeleted } = await require('./kids').removeFromFamily(tx, userId);
|
||||
await tx.kidsCheckIn.updateMany({ where: { checkedOutById: userId }, data: { checkedOutById: null } });
|
||||
|
||||
// Volunteer rosters: shifts still to come are given up; past ones stay for hours served.
|
||||
const shiftsGivenUp = await require('./volunteers').forgetVolunteer(tx, userId);
|
||||
|
||||
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length, familyDeleted, shiftsGivenUp };
|
||||
});
|
||||
|
||||
await clearTrustedDevices(userId);
|
||||
@@ -476,7 +485,7 @@ async function exportUserData(userId, db = prisma) {
|
||||
});
|
||||
if (!user) return null;
|
||||
|
||||
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests, notices] = await Promise.all([
|
||||
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests, notices, family, volunteering] = await Promise.all([
|
||||
db.registration.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
@@ -500,6 +509,8 @@ async function exportUserData(userId, db = prisma) {
|
||||
db.securityEvent.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, take: 100, select: { type: true, ip: true, device: true, createdAt: true } }),
|
||||
db.dataRequest.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, select: { type: true, status: true, details: true, response: true, createdAt: true, closedAt: true } }),
|
||||
db.noticeAcceptance.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, select: { version: true, source: true, createdAt: true } }),
|
||||
require('./kids').exportFamily(userId, db),
|
||||
require('./volunteers').exportVolunteering(userId, db),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -541,6 +552,8 @@ async function exportUserData(userId, db = prisma) {
|
||||
feedback: feedback.map(f => ({ event: f.form?.event?.title, at: f.createdAt, answers: f.answers.map(a => ({ question: a.field?.label, answer: a.value })) })),
|
||||
accountActivity: security.map(s => ({ type: s.type, ip: s.ip, device: s.device, at: s.createdAt })),
|
||||
dataRequests: requests,
|
||||
kidsFamily: family,
|
||||
volunteering,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -597,6 +610,10 @@ async function dataExportPdf(data) {
|
||||
heading('Certificates');
|
||||
for (const c of data.certificates) para(`${c.event} — ${c.code}${c.revoked ? ' (withdrawn)' : ''}`);
|
||||
}
|
||||
if (data.volunteering?.length) {
|
||||
heading('Serving (volunteer rosters)');
|
||||
for (const s of data.volunteering) para(`${fmt(s.from)} — ${s.role}, ${s.event}${s.checkedIn ? ' (checked in)' : ''}`);
|
||||
}
|
||||
if (data.feedback.length) {
|
||||
heading('Feedback you gave');
|
||||
for (const f of data.feedback) {
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
* notice is shown, so it follows Site settings and the operator register:
|
||||
* {org} {orgEmail} {website} {ioName} {ioEmail} {medicalRetentionDays}
|
||||
* {operators} — on a line of its own: one bullet per active operator.
|
||||
* {retention} — on a line of its own: one bullet per retention period (utils/retention.js).
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const PLACEHOLDERS = ['org', 'orgEmail', 'website', 'ioName', 'ioEmail', 'medicalRetentionDays', 'operators'];
|
||||
const PLACEHOLDERS = ['org', 'orgEmail', 'website', 'ioName', 'ioEmail', 'medicalRetentionDays', 'operators', 'retention'];
|
||||
const MAX_SECTIONS = 40;
|
||||
const MAX_TITLE = 200;
|
||||
const MAX_BODY = 20000;
|
||||
@@ -23,11 +24,12 @@ const DEFAULT_SECTIONS = [
|
||||
title: 'Plain English Summary',
|
||||
body: [
|
||||
"We respect your privacy — here's what you need to know:",
|
||||
'• We only collect info needed to run events: registrations, payments, tickets and check-in.',
|
||||
'• We store your info safely on South African servers.',
|
||||
'• We only collect info needed to run events: registrations, payments, tickets, check-in and, for children\'s ministry, kids check-in.',
|
||||
"• Your information is kept in this site's own database, run by the hosting provider listed in section 9. It isn't shared with other organisations that use the same software.",
|
||||
"• We don't sell your data, and only share it with the services listed in section 9, which process payments and deliver messages for us.",
|
||||
'• If you register other people or pass a ticket on, we only use the email or phone number you type to contact them. We never show you whether someone has an account with us.',
|
||||
'• For youth events, a parent or guardian gives consent. Medical details are encrypted, seen only by the event leaders who need them, and deleted shortly after the event.',
|
||||
"• For kids check-in, parents add their children's details once. Allergies, medical and custody notes are encrypted, shown only to kids-ministry staff, and deleted when a child hasn't been checked in for a while (section 7). Children are collected with a pickup code.",
|
||||
'• Online payments go through Yoco or PayFast; we never see your card details.',
|
||||
'• We send tickets, updates and security alerts by email and/or WhatsApp. News and announcements are sent only if you ask for them, and you can unsubscribe at any time.',
|
||||
'• Visit statistics are only collected if you click "Allow" on the cookie notice — no third-party analytics.',
|
||||
@@ -49,8 +51,10 @@ const DEFAULT_SECTIONS = [
|
||||
'We collect the following information when you interact with our platform:',
|
||||
'• Account information: name, email address, phone number, notification preference and password (stored only as an encrypted hash). If you use two-factor authentication, we keep which method you use; codes are short-lived and never stored in readable form.',
|
||||
'• Security records: sign-ins, password changes and similar account events, with the time, IP address and device type, so we can alert you and investigate misuse.',
|
||||
'• Event information: events you register for, ticket selections, discount codes used, attendee form answers, waitlist requests, ticket check-ins (when your ticket was scanned), and room or group allocations.',
|
||||
'• Details about other people you register (see section 3), and parental consent details for youth events (see section 8).',
|
||||
'• Event information: events you register for, ticket selections (including the sessions you chose on multi-session events), discount codes used, attendee form answers, waitlist requests, ticket check-ins (when your ticket was scanned, per session), room or group allocations, and completion certificates issued to you.',
|
||||
'• Your home campus, if the organisation has more than one campus and you choose one.',
|
||||
"• Kids check-in (if you use it): your family's name, your children's names, dates of birth, gender, allergies, medical notes and custody notes, the adults you allow to collect them (name, phone number, relationship), and each check-in — when, which room, the pickup code, who checked them in and out, and who collected them (see section 8).",
|
||||
'• Details about other people you register (see section 3), parental consent details for youth events, and children at kids check-in (see section 8).',
|
||||
'• Payment information: processed by our payment provider (Yoco or PayFast); we receive only payment status, amount and reference numbers. If you sponsor a spot, we record the amount and, unless you give anonymously, the name and message you choose to add.',
|
||||
"• Feedback: answers you give to post-event feedback forms. If an event's feedback is anonymous, your answers are not linked to your name.",
|
||||
'• Your choices: whether you want news and announcements, which version of this notice you accepted, and when you made each choice.',
|
||||
@@ -82,7 +86,10 @@ const DEFAULT_SECTIONS = [
|
||||
'• Send news and announcements about upcoming events, only if you asked for them. Every such email has an unsubscribe link, and you can reply STOP on WhatsApp.',
|
||||
'• Send security notifications for new logins, password changes, and account actions on your account.',
|
||||
'• Deliver your event tickets via email attachment and/or WhatsApp PDF (based on your preference).',
|
||||
'• Check tickets in at the door, including with scanners that keep working without an internet connection.',
|
||||
'• Check tickets in at the door, including with scanners that keep working without an internet connection, and keep attendance per session on multi-session events.',
|
||||
'• Send optional reminders before sessions, and issue completion certificates (each has a code anyone can use to check it is genuine: the check shows the name on the certificate and the event).',
|
||||
'• Check children in and out at kids check-in, send you their pickup code, and ask you to come to the room if a leader needs you.',
|
||||
'• Show you the events of your home campus first, if the organisation has more than one campus.',
|
||||
'• Offer places from a waitlist, arrange rooms and groups, and ask for parental consent on youth events.',
|
||||
'• Send you an optional feedback form after an event, with one reminder.',
|
||||
'• Manage user accounts and event attendance records, and give support when you ask for help.',
|
||||
@@ -98,7 +105,9 @@ const DEFAULT_SECTIONS = [
|
||||
'• Event registration confirmations and updates.',
|
||||
'• Payment receipts.',
|
||||
'• Your event tickets (as a PDF document).',
|
||||
'• Security alerts such as new login notifications, password changes, and account closure confirmations.',
|
||||
'• Security alerts such as new login notifications, password changes, and account closure confirmations, and sign-in codes if you chose WhatsApp for two-factor authentication.',
|
||||
'• Session reminders, if the event sends them.',
|
||||
'• Kids check-in: your children\'s pickup code, and a request to come to the room if a leader needs you. These never include medical or custody details. If you ask the desk to, the pickup code is also sent to one of your authorised adults.',
|
||||
'• News and announcements, only if you asked for them on WhatsApp. Reply STOP to any of these to stop them.',
|
||||
'WhatsApp communications are delivered through WAWP (wawp.net), a third-party WhatsApp Business API provider. Your mobile number is transmitted to WAWP solely for the purpose of delivering your messages.',
|
||||
'Security-critical messages (login alerts, password resets, account closures) are always sent via email regardless of your preference, and additionally sent via WhatsApp if your preference includes it.',
|
||||
@@ -112,7 +121,8 @@ const DEFAULT_SECTIONS = [
|
||||
id: 'storage',
|
||||
title: '6. Where We Store Your Data',
|
||||
body: [
|
||||
'All data is securely stored on cloud servers hosted in South Africa.',
|
||||
"Your information is stored in this site's own database, on the servers of the hosting provider listed in section 9 (where they are is shown there). Each organisation using this software has its own separate site and database.",
|
||||
'The software (Cross Events) is licensed from Cross Code. Every 15 minutes the site sends Cross Code\'s licence server technical health information (software version, uptime, disk space, errors) and totals — such as the number of accounts, events, registrations, tickets and check-ins, and income as a broad range (for example "R5 001–R20 000"). It never sends names, contact details, children\'s details or any other personal information.',
|
||||
'We use appropriate technical and organisational measures to protect your information from unauthorised access, alteration, or disclosure.',
|
||||
].join('\n'),
|
||||
},
|
||||
@@ -120,24 +130,28 @@ const DEFAULT_SECTIONS = [
|
||||
id: 'retention',
|
||||
title: '7. Data Retention',
|
||||
body: [
|
||||
'We retain your personal data for up to 5 years after your last event registration, or until you ask us to delete your account — whichever comes first.',
|
||||
'Medical and emergency-contact details from parental consent forms are deleted {medicalRetentionDays} days after the event ends. The record of who gave consent, when, and to which wording is kept.',
|
||||
'Detailed visit statistics are combined into anonymous daily totals after 90 days.',
|
||||
"Ticket lists downloaded to a staff member's scanner for offline check-in are deleted from that device when offline mode is ended.",
|
||||
'Records of support access to accounts (see section 9), of who exported personal information, and other audit records are kept for accountability.',
|
||||
'You can ask for your account to be deleted from the Privacy page in your account. Your account is closed straight away, and your personal information — your name and contact details, the people you registered, your form answers — is deleted within 30 days (after any upcoming event you registered for). Payment and registration records are kept without your name, because the law requires us to keep financial records.',
|
||||
'Backups of our database still contain deleted information until they are replaced, which happens automatically within a few weeks.',
|
||||
'We keep personal information only as long as we need it:',
|
||||
'{retention}',
|
||||
"• Ticket lists downloaded to a staff member's scanner for offline check-in are deleted from that device when offline mode is ended.",
|
||||
"• Family lists downloaded to a kids check-in desk for offline use stay on that device until the desk is synced and the list is refreshed or removed. Only kids-ministry staff can download them, and each download is recorded.",
|
||||
'You can ask for your account to be deleted at any time from the Privacy page in your account. Your account is closed straight away, and your personal information — your name and contact details, the people you registered, your form answers — is deleted within 30 days (after any upcoming event you registered for). Payment and registration records are kept without your name, because the law requires us to keep financial records.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'children',
|
||||
title: '8. Children, Parental Consent and Health Information',
|
||||
title: '8. Children, Parental Consent, Kids Check-in and Health Information',
|
||||
body: [
|
||||
'Users under 18 may only create an account or register for events with the consent and supervision of a parent or guardian.',
|
||||
'We do not knowingly collect or process personal information of minors without such consent.',
|
||||
"For youth events, each attendee's date of birth is required. For anyone under 18, the parent or guardian named at registration is sent a consent request by email and WhatsApp, where they review the event details and give or decline consent. Their typed name, the time, the wording agreed to, and the IP address and device type used are recorded as proof of consent. A young person can't be checked in until consent is given.",
|
||||
"The consent form may ask for medical information (such as allergies, medication and conditions) and an emergency contact. This is special personal information under POPIA. We collect it only with the parent's or guardian's consent, for the young person's safety at the event. It is stored encrypted and shown only to supervisors who need it, and each time the medical list is viewed this is recorded. It is deleted after the event as described in section 7.",
|
||||
'A parent or guardian may also say whether photos of the young person may be used.',
|
||||
'Kids check-in:',
|
||||
"• A parent adds their children under My account → Kids, or the kids desk adds them with the parent present. A parent who is added at the desk with only a phone number or email address gets an account they can activate.",
|
||||
"• Allergies, medical notes and custody notes (for example, someone who may not collect a child) are special personal information. We keep them only to keep your child safe, with your consent as their parent or guardian. They are stored encrypted, shown only to kids-ministry staff at the desk and in the rooms, and never put in a message. Opening the desk or room screens is recorded. Only parents, supervisors and administrators can change custody notes, and every change is recorded.",
|
||||
'• Each check-in gives you a pickup code. Anyone who has the code can collect your children, so share it only with people you trust. The adults you list as allowed to collect are shown to the room leader so they can check.',
|
||||
'• Each child is added to the event\'s attendance records (name, date of birth and gender) like any other attendee, so the organisation knows who attended.',
|
||||
"• A child's details are deleted when they haven't been checked in for the period in section 7. You can remove them yourself at any time.",
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
@@ -149,7 +163,9 @@ const DEFAULT_SECTIONS = [
|
||||
'• Anyone we are required to share it with under South African law.',
|
||||
'Within our organisation, people see only what they need:',
|
||||
"• Door staff see a ticket's holder name, ticket type and check-in status when scanning.",
|
||||
'• Group, room or bus leaders see the names, ages and contact numbers of the people in their group. Medical details are shown to supervisors only.',
|
||||
'• Group, room or bus leaders see the names, ages and contact numbers of the people in their group. Medical details from parental consent forms are shown to supervisors only.',
|
||||
"• Kids-ministry staff see the children checked in, with their allergies, medical and custody notes, the parents' names and phone numbers, and the adults allowed to collect them.",
|
||||
'• If the organisation has several campuses, a staff member may be limited to their own campuses\' events.',
|
||||
'• Supervisors and administrators manage registrations, payments and communication for events. Every export of personal information (reports, attendance registers, lists) is recorded.',
|
||||
'• Support access: to help with a problem (for example, a ticket not showing), an administrator may view the site as you see it for up to 30 minutes. This is strictly read-only — nothing can be changed, paid, registered or sent on your behalf. A reason must be given, and the session and every page viewed are recorded. The organisation may choose to email you whenever this happens.',
|
||||
'• Sponsors never see who was helped by a sponsorship; the public only sees how many spots were sponsored.',
|
||||
@@ -184,9 +200,12 @@ 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.',
|
||||
"• hope_events_kids_event and hope_events_kids_room — on a kids check-in device: the event and room last chosen.",
|
||||
"• hope_events_kids_device and hope_events_kids_offline_… — on a kids check-in desk in offline mode: the device's id, the list of families and children (with allergies, medical and custody notes), pickup codes kept for the device, and check-ins waiting to sync.",
|
||||
'Optional — only if you click "Allow" on the cookie notice:',
|
||||
'• hope_events_analytics_sid — a random, anonymous visit id. With it we count, in our own statistics, how many visitors view an event, start registering, and complete a registration. We record only that step, where the visit came from (for example a short link or campaign link) and the type of device — never your name, IP address or browsing history. Detailed records become anonymous daily totals after 90 days. If you choose "No thanks", nothing is counted and no visit id is created.',
|
||||
'Third parties:',
|
||||
@@ -199,7 +218,7 @@ const DEFAULT_SECTIONS = [
|
||||
id: 'security',
|
||||
title: '12. Security',
|
||||
body: [
|
||||
'Your personal data is encrypted during transmission and stored using industry-standard security protocols. Medical and emergency details are additionally encrypted in our database.',
|
||||
"Your personal data is encrypted during transmission and stored using industry-standard security protocols. Medical and emergency details, and children's allergies, medical and custody notes, are additionally encrypted in our database.",
|
||||
'Passwords are encrypted and cannot be viewed by anyone, including administrators.',
|
||||
'Two-factor authentication is available to everyone and is required for supervisors and administrators.',
|
||||
'Links we send for consent, feedback, ticket transfers and unsubscribing are personal. Only a secure fingerprint of most links is stored, not the link itself.',
|
||||
@@ -263,7 +282,7 @@ function cleanSections(input) {
|
||||
/** Settings and operators the placeholders are filled from. */
|
||||
async function noticeContext(db = prisma) {
|
||||
const { getSetting } = require('./settingsCache');
|
||||
const [org, orgEmail, website, ioName, ioEmail, medicalDays, operators] = await Promise.all([
|
||||
const [org, orgEmail, website, ioName, ioEmail, medicalDays, operators, retention] = await Promise.all([
|
||||
getSetting('org_name', process.env.ORG_NAME || 'Cross Code'),
|
||||
getSetting('org_email', ''),
|
||||
getSetting('legal_website_url', ''),
|
||||
@@ -271,6 +290,7 @@ async function noticeContext(db = prisma) {
|
||||
getSetting('legal_io_email', ''),
|
||||
getSetting('consent_medical_retention_days', ''),
|
||||
db.operator.findMany({ where: { active: true }, orderBy: [{ order: 'asc' }, { name: 'asc' }] }),
|
||||
require('./retention').noticeLines(db),
|
||||
]);
|
||||
// Unset legal fields show an obviously-unfinished placeholder, never someone's real details.
|
||||
return {
|
||||
@@ -281,9 +301,16 @@ async function noticeContext(db = prisma) {
|
||||
ioEmail: ioEmail || 'io@example.com',
|
||||
medicalRetentionDays: String(parseInt(medicalDays, 10) || 30),
|
||||
operators,
|
||||
retention,
|
||||
};
|
||||
}
|
||||
|
||||
// Placeholders that stand on a line of their own and become several bullet lines.
|
||||
const LIST_PLACEHOLDERS = {
|
||||
operators: ctx => (ctx.operators.length ? ctx.operators.map(operatorLine) : ['• (No service providers listed.)']),
|
||||
retention: ctx => ctx.retention || [],
|
||||
};
|
||||
|
||||
function operatorLine(o) {
|
||||
return `• ${o.name}${o.location ? ` (${o.location})` : ''} — ${o.purpose}. ${o.dataShared ? `Receives: ${o.dataShared}.` : ''}`.trim();
|
||||
}
|
||||
@@ -293,8 +320,11 @@ function renderSections(sections, ctx) {
|
||||
return sections.map(s => {
|
||||
const body = String(s.body || '')
|
||||
.split('\n')
|
||||
.flatMap(line => (line.trim() === '{operators}' ? (ctx.operators.length ? ctx.operators.map(operatorLine) : ['• (No service providers listed.)']) : [line]))
|
||||
.map(line => line.replace(/\{(\w+)\}/g, (m, key) => (key !== 'operators' && PLACEHOLDERS.includes(key) ? ctx[key] : m)))
|
||||
.flatMap(line => {
|
||||
const list = line.trim().match(/^\{(\w+)\}$/)?.[1];
|
||||
return list && LIST_PLACEHOLDERS[list] ? LIST_PLACEHOLDERS[list](ctx) : [line];
|
||||
})
|
||||
.map(line => line.replace(/\{(\w+)\}/g, (m, key) => (!LIST_PLACEHOLDERS[key] && PLACEHOLDERS.includes(key) ? ctx[key] : m)))
|
||||
.join('\n');
|
||||
return { id: s.id, title: s.title, body };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* The POPIA readiness checklist for Admin → Privacy: what's set up, what isn't, and where to
|
||||
* fix it. Checks that don't apply to this install (e.g. WhatsApp STOP without WhatsApp) are
|
||||
* left out of the score.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
// Generic names the operator register starts with, which should be replaced with the real provider.
|
||||
const GENERIC_OPERATORS = ['Email delivery provider', 'Hosting provider'];
|
||||
|
||||
async function readiness(now = new Date(), db = prisma) {
|
||||
const { getSetting } = require('./settingsCache');
|
||||
const setting = key => getSetting(key, '');
|
||||
const [ioName, ioEmail, operatorName, website, smtpHost] = await Promise.all([
|
||||
setting('legal_io_name'), setting('legal_io_email'), setting('legal_operator_name'), setting('legal_website_url'), setting('smtp_host'),
|
||||
]);
|
||||
|
||||
const retention = require('./retention');
|
||||
const { isEnabled, JOBS } = require('../jobs');
|
||||
const [notices, operators, policies, lastRun, overdue, openBreaches, webhookSeen, whatsappConfigured, backups] = await Promise.all([
|
||||
db.privacyNotice.count(),
|
||||
db.operator.findMany({ where: { active: true }, select: { name: true, location: true } }),
|
||||
retention.getPolicies(db),
|
||||
retention.lastRun(db),
|
||||
db.dataRequest.count({ where: { status: 'open', selfService: false, dueAt: { lt: now } } }),
|
||||
db.securityBreach.findMany({ where: { status: { not: 'closed' } }, select: { title: true, discoveredAt: true, regulatorNotifiedAt: true } }),
|
||||
db.appSetting.findUnique({ where: { key: 'wawp_webhook_last_seen' } }),
|
||||
require('./whatsapp').isConfigured().catch(() => false),
|
||||
require('./backupUtils').listBackups().catch(() => []),
|
||||
]);
|
||||
|
||||
const checks = [];
|
||||
const add = (key, label, ok, detail, href, applies = true) => checks.push({ key, label, ok: !!ok, detail, href, applies });
|
||||
|
||||
add('io', 'Information Officer named', ioName && ioEmail,
|
||||
ioName && ioEmail ? `${ioName} (${ioEmail})` : 'Set the Information Officer\'s name and email address — they appear on the privacy notice and contact page.',
|
||||
'/dashboard/admin/settings?tab=legal');
|
||||
add('legal', 'Responsible party and website filled in', operatorName && website,
|
||||
operatorName && website ? `${operatorName} · ${website}` : 'Fill in the operator/responsible party and website for the legal pages.',
|
||||
'/dashboard/admin/settings?tab=legal');
|
||||
add('notice', 'Privacy notice published', notices > 0,
|
||||
notices > 0 ? `${notices} version${notices === 1 ? '' : 's'} published` : 'Review the privacy notice and publish your own version (the built-in text is a starting point).',
|
||||
'/dashboard/admin/privacy?tab=notice');
|
||||
|
||||
const vague = operators.filter(o => !o.location || GENERIC_OPERATORS.includes(o.name)).map(o => o.name);
|
||||
add('operators', 'Operators listed with their details', operators.length && !vague.length,
|
||||
!operators.length ? 'List the services that process personal information for you.' : vague.length ? `Check: ${vague.join(', ')} (name the real provider and where it processes data).` : `${operators.length} operator${operators.length === 1 ? '' : 's'} listed`,
|
||||
'/dashboard/admin/privacy?tab=operators');
|
||||
|
||||
add('retention_reviewed', 'Retention periods reviewed', policies.reviewedAt,
|
||||
policies.reviewedAt ? `Saved ${new Date(policies.reviewedAt).toLocaleDateString('en-ZA')}` : 'Check the retention periods suit your organisation, and save them.',
|
||||
'/dashboard/admin/privacy?tab=retention');
|
||||
const retentionJob = JOBS.find(j => j.name === 'retention');
|
||||
const ranRecently = lastRun?.at && now - new Date(lastRun.at) < 2 * DAY;
|
||||
add('retention_running', 'Retention job running', isEnabled(retentionJob) && ranRecently,
|
||||
!isEnabled(retentionJob) ? 'The retention job is switched off (RETENTION_JOBS_ENABLED or JOBS_ENABLED).' : ranRecently ? `Last ran ${new Date(lastRun.at).toLocaleString('en-ZA')}` : 'It hasn\'t run in the last two days — check Background jobs.',
|
||||
'/dashboard/admin/settings?tab=jobs');
|
||||
|
||||
add('requests', 'Privacy requests answered on time', overdue === 0,
|
||||
overdue ? `${overdue} request${overdue === 1 ? ' is' : 's are'} overdue.` : 'None overdue',
|
||||
'/dashboard/admin/data-requests');
|
||||
const unreported = openBreaches.filter(b => !b.regulatorNotifiedAt && now - new Date(b.discoveredAt) > 3 * DAY);
|
||||
add('breaches', 'Security compromises reported', unreported.length === 0,
|
||||
unreported.length ? `Not yet reported to the Information Regulator: ${unreported.map(b => b.title).join(', ')}.` : openBreaches.length ? `${openBreaches.length} open, all reported` : 'Nothing open',
|
||||
'/dashboard/admin/privacy?tab=breaches');
|
||||
|
||||
const backendUrl = process.env.BACKEND_URL || '';
|
||||
// The .env.example value ("https://your-backend-domain.com") doesn't count.
|
||||
const realHttps = /^https:\/\//i.test(backendUrl) && !/your-|example\./i.test(backendUrl);
|
||||
add('https', 'Unsubscribe links work in mail apps', realHttps,
|
||||
realHttps ? backendUrl : 'Set BACKEND_URL on the server to the site\'s public https:// address (used by one-click unsubscribe and the WhatsApp webhook).',
|
||||
null);
|
||||
add('smtp', 'Email sending set up', smtpHost || process.env.SMTP_HOST || process.env.EMAIL_HOST,
|
||||
smtpHost || process.env.SMTP_HOST || process.env.EMAIL_HOST ? 'SMTP configured' : 'Without email, people don\'t get unsubscribe links, request confirmations or inactivity warnings.',
|
||||
'/dashboard/admin/settings?tab=email');
|
||||
const seen = webhookSeen?.value ? new Date(webhookSeen.value) : null;
|
||||
add('whatsapp_stop', 'WhatsApp STOP replies reach the site', seen && now - seen < 30 * DAY,
|
||||
seen ? `Last message from WAWP ${seen.toLocaleString('en-ZA')}${now - seen >= 30 * DAY ? ' — over a month ago, check the webhook' : ''}` : 'Paste the webhook URL from Site settings → WhatsApp into WAWP (events: message, session.status).',
|
||||
'/dashboard/admin/settings?tab=whatsapp', whatsappConfigured);
|
||||
const kidsEvents = await db.event.count({ where: { kidsCheckIn: true } });
|
||||
add('kids_retention', 'Children\'s kids check-in details expire', policies.policies.kids_profiles?.enabled,
|
||||
policies.policies.kids_profiles?.enabled ? `Deleted after ${retention.describeMonths(policies.policies.kids_profiles.months)} without a check-in` : 'Switch on the "Children at kids check-in" retention rule — it holds children\'s medical and custody notes.',
|
||||
'/dashboard/admin/privacy?tab=retention', kidsEvents > 0);
|
||||
const twoFactorOn = String(process.env.TWO_FACTOR_REQUIRED_ROLES || 'true').toLowerCase() !== 'false';
|
||||
add('two_factor', 'Two-factor login required for admins and supervisors', twoFactorOn,
|
||||
twoFactorOn ? 'Required' : 'TWO_FACTOR_REQUIRED_ROLES=false on the server — only for emergencies.', null);
|
||||
const lastBackup = backups[0]?.createdAt;
|
||||
add('backups', 'Backups running', lastBackup && now - new Date(lastBackup) < 2 * DAY,
|
||||
lastBackup ? `Last backup ${new Date(lastBackup).toLocaleString('en-ZA')}` : 'No backups found on this server.',
|
||||
'/dashboard/admin/settings?tab=backups');
|
||||
|
||||
const applicable = checks.filter(c => c.applies);
|
||||
const passed = applicable.filter(c => c.ok).length;
|
||||
return { score: applicable.length ? Math.round((passed / applicable.length) * 100) : 0, passed, total: applicable.length, checks };
|
||||
}
|
||||
|
||||
module.exports = { readiness, GENERIC_OPERATORS };
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* POPIA retention: how long each kind of personal information is kept, and the nightly
|
||||
* `retention` job that deletes or anonymises what's past its time.
|
||||
*
|
||||
* Policies live in AppSetting `retention_policies` (JSON) on top of the defaults below.
|
||||
* Medical details (consent_medical_retention_days), visit statistics (90 days, fixed) and
|
||||
* backups (backup_retain_count) have their own settings and jobs; they're shown alongside.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const SETTING_KEY = 'retention_policies';
|
||||
const LAST_RUN_KEY = 'retention_last_run';
|
||||
const WARNING_DAYS = 30;
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
// Spread a first big run over several nights instead of emailing everyone at once.
|
||||
const MAX_WARNINGS_PER_RUN = 300;
|
||||
const MAX_ANONYMISED_PER_RUN = 200;
|
||||
const BATCH = 500;
|
||||
|
||||
const POLICIES = {
|
||||
inactive_accounts: {
|
||||
label: 'Inactive accounts',
|
||||
description: 'Accounts with no login, registration or payment in this time are anonymised, after a warning 30 days before. Staff, supervisor and admin accounts are never touched, nor anyone with a registration for an upcoming event.',
|
||||
defaults: { enabled: true, months: 60 },
|
||||
minMonths: 12,
|
||||
},
|
||||
form_answers: {
|
||||
label: 'Attendee form answers',
|
||||
description: 'Answers to event forms are deleted this long after the event ends.',
|
||||
defaults: { enabled: true, months: 12 },
|
||||
minMonths: 1,
|
||||
},
|
||||
login_history: {
|
||||
label: 'Login history',
|
||||
description: 'Logins, password changes and similar account events (with IP address and device).',
|
||||
defaults: { enabled: true, months: 24 },
|
||||
minMonths: 3,
|
||||
},
|
||||
payment_details: {
|
||||
label: 'Payer details in payment notifications',
|
||||
description: 'Names and email addresses that Yoco and PayFast send with a payment are removed. The payment itself (amount, date, method) is always kept for the accounts.',
|
||||
defaults: { enabled: true, months: 24 },
|
||||
minMonths: 3,
|
||||
},
|
||||
kids_profiles: {
|
||||
label: 'Children at kids check-in',
|
||||
description: 'A child\'s profile (name, date of birth, allergies, medical and custody notes) is deleted when they haven\'t been checked in for this long, then families left without children. Who collected a child is cleared from check-ins this old; the attendance itself stays.',
|
||||
defaults: { enabled: true, months: 6 },
|
||||
minMonths: 1,
|
||||
},
|
||||
audit_logs: {
|
||||
label: 'Audit and access logs',
|
||||
description: 'The audit log, the access log and "view as" sessions.',
|
||||
defaults: { enabled: true, months: 60 },
|
||||
minMonths: 12,
|
||||
},
|
||||
};
|
||||
|
||||
const MAX_MONTHS = 240;
|
||||
|
||||
function monthsAgo(months, now = new Date()) {
|
||||
const d = new Date(now);
|
||||
d.setMonth(d.getMonth() - months);
|
||||
return d;
|
||||
}
|
||||
|
||||
function describeMonths(months) {
|
||||
return months % 12 === 0 ? `${months / 12} year${months === 12 ? '' : 's'}` : `${months} month${months === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
/** The policies in force (defaults overlaid with what the admin saved), and when they were last reviewed. */
|
||||
async function getPolicies(db = prisma) {
|
||||
const row = await db.appSetting.findUnique({ where: { key: SETTING_KEY } });
|
||||
let saved = {};
|
||||
try { saved = row?.value ? JSON.parse(row.value) : {}; } catch { saved = {}; }
|
||||
const policies = {};
|
||||
for (const [key, def] of Object.entries(POLICIES)) {
|
||||
const s = saved.policies?.[key] || {};
|
||||
policies[key] = {
|
||||
enabled: typeof s.enabled === 'boolean' ? s.enabled : def.defaults.enabled,
|
||||
months: Number.isInteger(s.months) ? s.months : def.defaults.months,
|
||||
};
|
||||
}
|
||||
return { policies, reviewedAt: saved.reviewedAt || null, reviewedBy: saved.reviewedBy || null };
|
||||
}
|
||||
|
||||
/** Save the admin's policies (validated). Throws a message fit for the admin. */
|
||||
async function savePolicies(input, { reviewedBy } = {}, db = prisma) {
|
||||
const policies = {};
|
||||
for (const [key, def] of Object.entries(POLICIES)) {
|
||||
const p = input?.[key] || {};
|
||||
const months = parseInt(p.months, 10);
|
||||
if (!Number.isInteger(months) || months < def.minMonths || months > MAX_MONTHS) {
|
||||
throw new Error(`${def.label}: choose between ${def.minMonths} and ${MAX_MONTHS} months.`);
|
||||
}
|
||||
policies[key] = { enabled: p.enabled !== false, months };
|
||||
}
|
||||
const value = JSON.stringify({ policies, reviewedAt: new Date().toISOString(), reviewedBy: reviewedBy || null });
|
||||
await db.appSetting.upsert({ where: { key: SETTING_KEY }, update: { value }, create: { key: SETTING_KEY, value } });
|
||||
return getPolicies(db);
|
||||
}
|
||||
|
||||
// ─── Inactive accounts ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ordinary accounts (role user, not already anonymised) whose last sign of life — account
|
||||
* created, last login, a registration (or the end of the event it was for), being named as
|
||||
* an attendee, a payment — is before `before`.
|
||||
* warned: false — not warned since that last activity (a warning before it no longer counts);
|
||||
* otherwise — warned since their last activity, on or before `warnedBefore`.
|
||||
*/
|
||||
async function inactiveAccounts(before, { warned, warnedBefore, limit }, db = prisma) {
|
||||
const rows = await db.$queryRawUnsafe(`
|
||||
SELECT "id" FROM (
|
||||
SELECT u."id", u."createdAt", u."inactivityWarnedAt" AS warned_at, GREATEST(
|
||||
u."createdAt",
|
||||
COALESCE(u."lastLoginAt", 'epoch'),
|
||||
COALESCE((SELECT MAX(GREATEST(r."createdAt", e."endDate")) FROM "Registration" r JOIN "Event" e ON e."id" = r."eventId" WHERE r."userId" = u."id"), 'epoch'),
|
||||
COALESCE((SELECT MAX(a."createdAt") FROM "Attendee" a WHERE a."userId" = u."id"), 'epoch'),
|
||||
COALESCE((SELECT MAX(p."createdAt") FROM "Payment" p WHERE p."userId" = u."id"), 'epoch')
|
||||
) AS last_active
|
||||
FROM "User" u
|
||||
WHERE u."role" = 'user'
|
||||
AND u."email" NOT LIKE '%@deleted.invalid' AND u."email" NOT LIKE '%@deleted.local'
|
||||
) x
|
||||
WHERE last_active < $1
|
||||
AND ${warned === false ? '(warned_at IS NULL OR warned_at < last_active)' : 'warned_at >= last_active AND warned_at <= $2'}
|
||||
ORDER BY "createdAt"
|
||||
LIMIT ${Math.max(1, Math.min(limit || BATCH, 5000))}`,
|
||||
before, ...(warned === false ? [] : [warnedBefore]));
|
||||
return rows.map(r => r.id);
|
||||
}
|
||||
|
||||
async function warnInactive(userId, deleteOn, months, db = prisma) {
|
||||
const user = await db.user.findUnique({ where: { id: userId }, select: { id: true, name: true, email: true, phoneNumber: true } });
|
||||
if (!user) return false;
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
const org = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
const url = String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
const when = deleteOn.toLocaleDateString('en-ZA', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Africa/Johannesburg' });
|
||||
const text = `Your ${org} account hasn't been used for ${describeMonths(months)}. To protect your personal information, we'll delete it on ${when}. If you'd like to keep your account, just log in before then: ${url}/login`;
|
||||
|
||||
const realEmail = user.email && !/@(guest\.local|deleted\.invalid|deleted\.local)$/.test(user.email);
|
||||
if (realEmail) {
|
||||
const { sendMail, buildPrivacyEmail } = require('./email');
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject: `Your ${org} account will be deleted on ${when}`,
|
||||
...buildPrivacyEmail({ name: user.name, heading: 'Your account will be deleted', paragraphs: [text, 'If you do nothing, your account is closed and your personal information deleted. Payment records are kept without your name, as the law requires.'] }),
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
const { waTextAny } = require('./notify');
|
||||
await waTextAny(user, text);
|
||||
}
|
||||
await db.user.update({ where: { id: userId }, data: { inactivityWarnedAt: new Date() } });
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runInactiveAccounts(policy, now, db = prisma) {
|
||||
const cutoff = monthsAgo(policy.months, now);
|
||||
const deleteOn = new Date(now.getTime() + WARNING_DAYS * DAY);
|
||||
const { anonymiseUser, deletionBlockers } = require('./privacy');
|
||||
|
||||
// Warn those who'll pass the limit within the warning period.
|
||||
const toWarn = await inactiveAccounts(new Date(cutoff.getTime() + WARNING_DAYS * DAY), { warned: false, limit: MAX_WARNINGS_PER_RUN }, db);
|
||||
let warned = 0;
|
||||
for (const id of toWarn) if (await warnInactive(id, deleteOn, policy.months, db)) warned++;
|
||||
|
||||
// Anonymise those past the limit who were warned at least 30 days ago and still haven't come back.
|
||||
const due = await inactiveAccounts(cutoff, { warnedBefore: new Date(now.getTime() - WARNING_DAYS * DAY), limit: MAX_ANONYMISED_PER_RUN }, db);
|
||||
let anonymised = 0;
|
||||
let waiting = 0;
|
||||
for (const id of due) {
|
||||
if ((await deletionBlockers(id, db)).length) { waiting++; continue; }
|
||||
await anonymiseUser(id, db);
|
||||
await db.dataRequest.updateMany({ where: { userId: id, type: 'deletion', status: 'open' }, data: { status: 'completed', closedAt: now, response: 'Deleted after a long period without use.' } });
|
||||
anonymised++;
|
||||
}
|
||||
return { warned, anonymised, waiting };
|
||||
}
|
||||
|
||||
// ─── The other policies ───────────────────────────────────────────────────────
|
||||
|
||||
async function runFormAnswers(policy, now, db = prisma) {
|
||||
const cutoff = monthsAgo(policy.months, now);
|
||||
const where = { response: { registration: { event: { endDate: { lt: cutoff } } } } };
|
||||
const answers = await db.formAnswer.deleteMany({ where });
|
||||
const drafts = await db.formDraft.deleteMany({ where: { registration: { event: { endDate: { lt: cutoff } } } } });
|
||||
return { answers: answers.count, drafts: drafts.count };
|
||||
}
|
||||
|
||||
async function runLoginHistory(policy, now, db = prisma) {
|
||||
const res = await db.securityEvent.deleteMany({ where: { createdAt: { lt: monthsAgo(policy.months, now) } } });
|
||||
return { deleted: res.count };
|
||||
}
|
||||
|
||||
// Rows not scrubbed yet carry no "_scrubbed" key; scrubbed ones get it so they're skipped next time.
|
||||
async function scrubJsonColumn(table, column, cutoff, db) {
|
||||
const { scrubPersonal } = require('./privacy');
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const rows = await db.$queryRawUnsafe(
|
||||
`SELECT "id", "${column}" AS data FROM "${table}" WHERE "createdAt" < $1 AND "${column}" IS NOT NULL AND jsonb_typeof("${column}") = 'object' AND NOT ("${column}" ? '_scrubbed') LIMIT ${BATCH}`,
|
||||
cutoff,
|
||||
);
|
||||
if (!rows.length) break;
|
||||
for (const r of rows) {
|
||||
const model = table === 'ProviderTransaction' ? db.providerTransaction : db.providerCheckout;
|
||||
await model.update({ where: { id: r.id }, data: { [column]: { ...scrubPersonal(r.data), _scrubbed: true } } });
|
||||
}
|
||||
total += rows.length;
|
||||
if (rows.length < BATCH) break;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
async function runPaymentDetails(policy, now, db = prisma) {
|
||||
const cutoff = monthsAgo(policy.months, now);
|
||||
return {
|
||||
notifications: await scrubJsonColumn('ProviderTransaction', 'raw', cutoff, db),
|
||||
checkouts: await scrubJsonColumn('ProviderCheckout', 'request', cutoff, db),
|
||||
};
|
||||
}
|
||||
|
||||
async function runAuditLogs(policy, now, db = prisma) {
|
||||
const cutoff = monthsAgo(policy.months, now);
|
||||
const [audit, access, sessions] = await Promise.all([
|
||||
db.adminAuditLog.deleteMany({ where: { createdAt: { lt: cutoff } } }),
|
||||
db.dataAccessLog.deleteMany({ where: { createdAt: { lt: cutoff } } }),
|
||||
db.impersonationSession.deleteMany({ where: { startedAt: { lt: cutoff } } }),
|
||||
]);
|
||||
return { audit: audit.count, access: access.count, viewAsSessions: sessions.count };
|
||||
}
|
||||
|
||||
async function runKidsProfiles(policy, now, db = prisma) {
|
||||
return require('./kids').runKidsRetention(monthsAgo(policy.months, now), db);
|
||||
}
|
||||
|
||||
const RUNNERS = {
|
||||
kids_profiles: runKidsProfiles,
|
||||
inactive_accounts: runInactiveAccounts,
|
||||
form_answers: runFormAnswers,
|
||||
login_history: runLoginHistory,
|
||||
payment_details: runPaymentDetails,
|
||||
audit_logs: runAuditLogs,
|
||||
};
|
||||
|
||||
/** The nightly job: apply every switched-on policy. */
|
||||
async function runRetention(now = new Date(), db = prisma) {
|
||||
const { policies } = await getPolicies(db);
|
||||
const result = {};
|
||||
for (const [key, policy] of Object.entries(policies)) {
|
||||
if (!policy.enabled) continue;
|
||||
try {
|
||||
result[key] = await RUNNERS[key](policy, now, db);
|
||||
} catch (e) {
|
||||
result[key] = { error: e?.message || String(e) };
|
||||
console.warn(`[retention] ${key} failed:`, e?.message || e);
|
||||
}
|
||||
}
|
||||
const value = JSON.stringify({ at: now.toISOString(), result });
|
||||
await db.appSetting.upsert({ where: { key: LAST_RUN_KEY }, update: { value }, create: { key: LAST_RUN_KEY, value } });
|
||||
const { isNoop } = require('../jobs');
|
||||
if (!isNoop(result)) {
|
||||
require('./adminAudit').logAdminAction({ actorId: null, actorRole: 'system', action: 'retention_run', targetType: 'retention', metadata: result });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function lastRun(db = prisma) {
|
||||
const row = await db.appSetting.findUnique({ where: { key: LAST_RUN_KEY } });
|
||||
try { return row?.value ? JSON.parse(row.value) : null; } catch { return null; }
|
||||
}
|
||||
|
||||
/** How much each policy would affect if the job ran now — for the admin page. */
|
||||
async function preview(policies, now = new Date(), db = prisma) {
|
||||
const out = {};
|
||||
const p = policies;
|
||||
const inactiveCutoff = monthsAgo(p.inactive_accounts.months, now);
|
||||
const [toWarn, warnedDue, formAnswers, logins, audit, kidsProfiles] = await Promise.all([
|
||||
inactiveAccounts(new Date(inactiveCutoff.getTime() + WARNING_DAYS * DAY), { warned: false, limit: 5000 }, db),
|
||||
inactiveAccounts(inactiveCutoff, { warnedBefore: new Date(now.getTime() - WARNING_DAYS * DAY), limit: 5000 }, db),
|
||||
db.formAnswer.count({ where: { response: { registration: { event: { endDate: { lt: monthsAgo(p.form_answers.months, now) } } } } } }),
|
||||
db.securityEvent.count({ where: { createdAt: { lt: monthsAgo(p.login_history.months, now) } } }),
|
||||
db.adminAuditLog.count({ where: { createdAt: { lt: monthsAgo(p.audit_logs.months, now) } } }),
|
||||
require('./kids').previewKidsRetention(monthsAgo(p.kids_profiles.months, now), db),
|
||||
]);
|
||||
const payments = await db.$queryRawUnsafe(
|
||||
`SELECT COUNT(*)::int AS n FROM "ProviderTransaction" WHERE "createdAt" < $1 AND "raw" IS NOT NULL AND jsonb_typeof("raw") = 'object' AND NOT ("raw" ? '_scrubbed')`,
|
||||
monthsAgo(p.payment_details.months, now),
|
||||
);
|
||||
out.inactive_accounts = { toWarn: toWarn.length, toAnonymise: warnedDue.length };
|
||||
out.form_answers = { count: formAnswers };
|
||||
out.login_history = { count: logins };
|
||||
out.payment_details = { count: payments[0]?.n || 0 };
|
||||
out.audit_logs = { count: audit };
|
||||
out.kids_profiles = kidsProfiles;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One line per policy for the privacy notice's {retention} placeholder. */
|
||||
async function noticeLines(db = prisma) {
|
||||
const { policies } = await getPolicies(db);
|
||||
const { getSetting } = require('./settingsCache');
|
||||
const [medical, backups] = await Promise.all([getSetting('consent_medical_retention_days', ''), getSetting('backup_retain_count', '')]);
|
||||
const line = (key, on, off) => (policies[key].enabled ? on(describeMonths(policies[key].months)) : off);
|
||||
return [
|
||||
line('inactive_accounts', t => `• Accounts with no login, registration or payment for ${t} are deleted, after a warning 30 days before.`, '• Accounts are kept until you ask us to delete them.'),
|
||||
line('form_answers', t => `• Answers to event forms are deleted ${t} after the event.`, '• Answers to event forms are kept until you ask us to delete them.'),
|
||||
`• Medical and emergency-contact details from parental consent forms are deleted ${parseInt(medical, 10) || 30} days after the event. The record of who gave consent, when and to which wording is kept.`,
|
||||
line('login_history', t => `• Login history (time, IP address, device) is kept for ${t}.`, '• Login history is kept until you ask us to delete it.'),
|
||||
line('payment_details', t => `• Payer names and email addresses received with payments are removed after ${t}. Payment records themselves (amount, date, method) are kept as tax law requires.`, '• Payment records are kept as tax law requires.'),
|
||||
line('kids_profiles', t => `• Children's details for kids check-in (including allergies, medical and custody notes) are deleted when a child hasn't been checked in for ${t}.`, '• Children\'s details for kids check-in are kept until a parent removes them.'),
|
||||
line('audit_logs', t => `• Audit and access records (who did what in the system) are kept for ${t}.`, '• Audit and access records are kept for accountability.'),
|
||||
'• Detailed visit statistics become anonymous daily totals after 90 days.',
|
||||
`• Database backups are kept for about ${parseInt(backups, 10) || 14} days, so deleted information stays in them until they are replaced.`,
|
||||
];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
POLICIES,
|
||||
WARNING_DAYS,
|
||||
getPolicies,
|
||||
savePolicies,
|
||||
inactiveAccounts,
|
||||
runRetention,
|
||||
lastRun,
|
||||
preview,
|
||||
noticeLines,
|
||||
describeMonths,
|
||||
monthsAgo,
|
||||
};
|
||||
@@ -0,0 +1,782 @@
|
||||
/**
|
||||
* Volunteer rosters (1.46.0). Who serves where at an event:
|
||||
*
|
||||
* - Serving roles (Welcome, Parking, Kids, Sound…) belong to one event, or — on a multi-session
|
||||
* event — to one session, with the number of people needed and optional shift times (default:
|
||||
* the session's or event's times). Rosters never repeat: each event/session has its own roles.
|
||||
* - Anyone with an account can sign up for a role that isn't full; supervisors can also assign
|
||||
* people (even beyond the number needed). Nobody can be in two roles whose shifts overlap.
|
||||
* - Volunteers get a reminder the day before their shift. "Can't make it" asks for cover: the
|
||||
* request shows on the Serving page and goes to the event's other volunteers, and the place
|
||||
* moves as soon as someone accepts — the event's notify recipients are told, nobody approves.
|
||||
* - On the day volunteers check themselves in by scanning the event's volunteer QR code (or staff
|
||||
* tick them in). A role linked to a kids room puts its volunteers on duty in that room when they
|
||||
* check in, so they count for the room's leader-to-children ratio.
|
||||
* - A free volunteer place on a ticket of the event is off by default; a supervisor turns it on
|
||||
* per event by choosing the ticket. Volunteers who already have a ticket keep theirs.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const prisma = require('../config/db');
|
||||
const sessionsUtil = require('./sessions');
|
||||
|
||||
const MAX_NEEDED = 200;
|
||||
const MAX_ROLES = 100;
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
// Reminders go out this long before a shift, to people who signed up before then.
|
||||
const REMIND_BEFORE_MS = 24 * HOUR;
|
||||
// What the check-in QR code holds: the prefix, then the event's token.
|
||||
const QR_PREFIX = 'SERVE-';
|
||||
|
||||
function volunteerError(message, statusCode = 400, code = undefined) {
|
||||
return Object.assign(new Error(message), { statusCode, ...(code ? { code } : {}) });
|
||||
}
|
||||
|
||||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** A role's shift: its own times, else its session's, else the event's. */
|
||||
function shiftOf(role, event) {
|
||||
const session = role.session || (role.sessionId ? (event.sessions || []).find(s => s.id === role.sessionId) : null);
|
||||
return {
|
||||
startsAt: new Date(role.startsAt || session?.startsAt || event.startDate),
|
||||
endsAt: new Date(role.endsAt || session?.endsAt || event.endDate),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether two shifts overlap (touching ends don't count: 08:00–10:00 and 10:00–12:00 are fine). */
|
||||
function overlaps(a, b) {
|
||||
return new Date(a.startsAt) < new Date(b.endsAt) && new Date(b.startsAt) < new Date(a.endsAt);
|
||||
}
|
||||
|
||||
/** Hours in a shift, to the nearest quarter hour. */
|
||||
function shiftHours(shift) {
|
||||
const h = (new Date(shift.endsAt) - new Date(shift.startsAt)) / HOUR;
|
||||
return h > 0 ? Math.round(h * 4) / 4 : 0;
|
||||
}
|
||||
|
||||
/** { needed, filled, open, full } for a role with `filled` volunteers. */
|
||||
function fillOf(needed, filled) {
|
||||
return { needed, filled, open: Math.max(0, needed - filled), full: filled >= needed };
|
||||
}
|
||||
|
||||
/** Volunteers can check in on the day(s) of their shift, until it ends. */
|
||||
function checkInOpen(shift, now = new Date()) {
|
||||
const today = sessionsUtil.dayKey(now);
|
||||
return today >= sessionsUtil.dayKey(shift.startsAt) && today <= sessionsUtil.dayKey(shift.endsAt) && new Date(now) <= new Date(shift.endsAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a slot's reminder is due: not sent yet, the shift starts within the next 24 hours, and
|
||||
* the person signed up more than half an hour ago (someone who just signed up doesn't need one).
|
||||
*/
|
||||
function reminderDue({ remindedAt, createdAt }, shift, now = new Date()) {
|
||||
if (remindedAt) return false;
|
||||
const t = new Date(now).getTime();
|
||||
const start = new Date(shift.startsAt).getTime();
|
||||
return start > t && start - t <= REMIND_BEFORE_MS && t - new Date(createdAt).getTime() > HOUR / 2;
|
||||
}
|
||||
|
||||
/** The kids room occasion a check-in counts for (see utils/kids.js occasionFor): the session, or today. */
|
||||
function kidsOccasion(role, now = new Date()) {
|
||||
return role.sessionId || sessionsUtil.dayKey(now);
|
||||
}
|
||||
|
||||
/** Ticket options only staff hand out — the kids check-in and volunteer tickets — never sold publicly. */
|
||||
function reservedOptionIds(event) {
|
||||
return [event?.kidsOptionId, event?.volunteerOptionId].filter(Boolean);
|
||||
}
|
||||
|
||||
/** The event's options without the reserved ones (for public event pages and lists). */
|
||||
function publicOptions(event) {
|
||||
const reserved = new Set(reservedOptionIds(event));
|
||||
return (event?.eventOptions || []).filter(o => !reserved.has(o.id));
|
||||
}
|
||||
|
||||
function fmtWhen(d) {
|
||||
return new Date(d).toLocaleString('en-GB', { timeZone: sessionsUtil.TIMEZONE, weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function fmtTime(d) {
|
||||
return new Date(d).toLocaleTimeString('en-GB', { timeZone: sessionsUtil.TIMEZONE, hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/** "Sat 7 Nov, 08:00–10:30" (the end date is added when the shift runs past midnight). */
|
||||
function shiftText(shift) {
|
||||
const sameDay = sessionsUtil.dayKey(shift.startsAt) === sessionsUtil.dayKey(shift.endsAt);
|
||||
return `${fmtWhen(shift.startsAt)}–${sameDay ? fmtTime(shift.endsAt) : fmtWhen(shift.endsAt)}`;
|
||||
}
|
||||
|
||||
function cleanText(v, max) {
|
||||
return String(v ?? '').trim().slice(0, max);
|
||||
}
|
||||
|
||||
// ─── Loading ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const EVENT_SELECT = {
|
||||
id: true, title: true, startDate: true, endDate: true, scheduleType: true, isActive: true, location: true, campusId: true,
|
||||
kidsCheckIn: true, kidsRoomSetId: true, kidsOptionId: true, volunteerOptionId: true, volunteerCheckInToken: true,
|
||||
sessions: { orderBy: { startsAt: 'asc' } },
|
||||
};
|
||||
|
||||
async function loadEvent(eventId, db = prisma) {
|
||||
const event = await db.event.findUnique({ where: { id: String(eventId || '') }, select: EVENT_SELECT });
|
||||
if (!event) throw volunteerError('Event not found', 404);
|
||||
return event;
|
||||
}
|
||||
|
||||
const USER_SELECT = { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true };
|
||||
|
||||
const ROLE_INCLUDE = {
|
||||
session: true,
|
||||
kidsRoom: { select: { id: true, name: true } },
|
||||
slots: {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: { user: { select: USER_SELECT }, assignedBy: { select: { name: true } }, checkedInBy: { select: { name: true } } },
|
||||
},
|
||||
swaps: { where: { status: 'open' }, orderBy: { createdAt: 'asc' } },
|
||||
};
|
||||
|
||||
const realEmail = e => (e && !/@(guest\.local|deleted\.invalid|deleted\.local)$/.test(e) ? e : null);
|
||||
|
||||
/**
|
||||
* A role for the API. `staff` adds the volunteers (names, contact, check-ins); others see only
|
||||
* how full it is, whether they're in it, and open cover requests.
|
||||
*/
|
||||
function roleView(role, event, { staff = false, userId = null, now = new Date() } = {}) {
|
||||
const shift = shiftOf(role, event);
|
||||
const sessions = sessionsUtil.activeSessions(event.sessions);
|
||||
const mine = userId ? role.slots.find(s => s.userId === userId) : null;
|
||||
const swapBySlot = Object.fromEntries(role.swaps.map(w => [w.slotId, w]));
|
||||
return {
|
||||
id: role.id,
|
||||
eventId: role.eventId,
|
||||
sessionId: role.sessionId,
|
||||
sessionLabel: role.session ? sessionsUtil.sessionLabel(role.session, sessions) : null,
|
||||
name: role.name,
|
||||
description: role.description || '',
|
||||
startsAt: role.startsAt,
|
||||
endsAt: role.endsAt,
|
||||
shift: { startsAt: shift.startsAt, endsAt: shift.endsAt, text: shiftText(shift), hours: shiftHours(shift) },
|
||||
kidsRoom: role.kidsRoom || null,
|
||||
order: role.order,
|
||||
past: new Date(shift.endsAt) < new Date(now),
|
||||
...fillOf(role.needed, role.slots.length),
|
||||
mine: mine ? { slotId: mine.id, checkedInAt: mine.checkedInAt, swapId: swapBySlot[mine.id]?.id || null } : null,
|
||||
coverNeeded: role.swaps.filter(w => w.fromUserId !== userId).map(w => ({ swapId: w.id, note: w.note || '' })),
|
||||
...(staff ? {
|
||||
volunteers: role.slots.map(s => ({
|
||||
slotId: s.id,
|
||||
userId: s.user.id,
|
||||
name: s.user.name,
|
||||
phone: s.user.phoneNumber || null,
|
||||
email: realEmail(s.user.email),
|
||||
assignedBy: s.assignedBy?.name || null,
|
||||
checkedInAt: s.checkedInAt,
|
||||
checkedInBy: s.checkedInBy?.name || null,
|
||||
checkInMethod: s.checkInMethod,
|
||||
swapOpen: !!swapBySlot[s.id],
|
||||
hasTicket: !!s.registrationId,
|
||||
})),
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** An event's roster: its roles in order (by session, then shift start, then order). */
|
||||
async function roster(eventId, { staff = false, userId = null, now = new Date() } = {}, db = prisma) {
|
||||
const event = await loadEvent(eventId, db);
|
||||
const roles = await db.servingRole.findMany({ where: { eventId: event.id }, include: ROLE_INCLUDE });
|
||||
const views = roles.map(r => roleView(r, event, { staff, userId, now }))
|
||||
.sort((a, b) => new Date(a.shift.startsAt) - new Date(b.shift.startsAt) || a.order - b.order || a.name.localeCompare(b.name));
|
||||
const totals = views.reduce((t, r) => ({ needed: t.needed + r.needed, filled: t.filled + Math.min(r.filled, r.needed), checkedIn: t.checkedIn + (r.volunteers || []).filter(v => v.checkedInAt).length }), { needed: 0, filled: 0, checkedIn: 0 });
|
||||
return {
|
||||
event: { id: event.id, title: event.title, startDate: event.startDate, endDate: event.endDate, scheduleType: event.scheduleType, location: event.location, kidsCheckIn: event.kidsCheckIn },
|
||||
sessions: sessionsUtil.activeSessions(event.sessions).map(s => ({ id: s.id, label: sessionsUtil.sessionLabel(s, event.sessions), startsAt: s.startsAt, endsAt: s.endsAt })),
|
||||
roles: views,
|
||||
totals: { ...totals, fillRate: totals.needed ? Math.round((totals.filled / totals.needed) * 100) : null },
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Roles (supervisors) ──────────────────────────────────────────────────────
|
||||
|
||||
function parseTime(v, what) {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
const d = new Date(v);
|
||||
if (Number.isNaN(d.getTime())) throw volunteerError(`Enter a valid ${what}.`);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Check and clean a role from the request (`existing` when changing one). */
|
||||
async function cleanRole(event, input, existing = null, db = prisma) {
|
||||
const b = input || {};
|
||||
const data = {};
|
||||
if (b.name !== undefined || !existing) {
|
||||
data.name = cleanText(b.name, 60);
|
||||
if (!data.name) throw volunteerError('Enter a name for the role (e.g. Welcome, Parking, Sound).');
|
||||
}
|
||||
if (b.description !== undefined) data.description = cleanText(b.description, 500) || null;
|
||||
if (b.needed !== undefined || !existing) {
|
||||
const n = parseInt(b.needed ?? 1, 10);
|
||||
if (!Number.isFinite(n) || n < 1 || n > MAX_NEEDED) throw volunteerError(`The number of people needed must be from 1 to ${MAX_NEEDED}.`);
|
||||
data.needed = n;
|
||||
}
|
||||
if (b.sessionId !== undefined || !existing) {
|
||||
if (event.scheduleType === 'sessions') {
|
||||
const session = event.sessions.find(s => s.id === b.sessionId && !s.cancelledAt);
|
||||
if (!session) throw volunteerError('Choose the session this role is for.');
|
||||
data.sessionId = session.id;
|
||||
} else data.sessionId = null;
|
||||
}
|
||||
const startsAt = parseTime(b.startsAt, 'start time');
|
||||
const endsAt = parseTime(b.endsAt, 'end time');
|
||||
if (startsAt !== undefined) data.startsAt = startsAt;
|
||||
if (endsAt !== undefined) data.endsAt = endsAt;
|
||||
const shift = shiftOf({ ...existing, ...data, session: undefined }, event);
|
||||
if (shift.endsAt <= shift.startsAt) throw volunteerError('The shift has to end after it starts.');
|
||||
if (b.kidsRoomId !== undefined) {
|
||||
if (!b.kidsRoomId) data.kidsRoomId = null;
|
||||
else {
|
||||
const room = event.kidsRoomSetId ? await db.allocationGroup.findFirst({ where: { id: String(b.kidsRoomId), setId: event.kidsRoomSetId } }) : null;
|
||||
if (!room) throw volunteerError('Choose one of this event\'s kids rooms (set up under Kids check-in setup).');
|
||||
data.kidsRoomId = room.id;
|
||||
}
|
||||
}
|
||||
if (b.order !== undefined) data.order = Math.max(0, Math.min(1000, parseInt(b.order, 10) || 0));
|
||||
return data;
|
||||
}
|
||||
|
||||
async function createRole(eventId, input, db = prisma) {
|
||||
const event = await loadEvent(eventId, db);
|
||||
if (await db.servingRole.count({ where: { eventId: event.id } }) >= MAX_ROLES) throw volunteerError(`An event can have up to ${MAX_ROLES} serving roles.`);
|
||||
const data = await cleanRole(event, input, null, db);
|
||||
if (input?.order === undefined) data.order = await db.servingRole.count({ where: { eventId: event.id, sessionId: data.sessionId } });
|
||||
return db.servingRole.create({ data: { ...data, eventId: event.id } });
|
||||
}
|
||||
|
||||
async function updateRole(roleId, input, db = prisma) {
|
||||
const existing = await db.servingRole.findUnique({ where: { id: String(roleId || '') } });
|
||||
if (!existing) throw volunteerError('Role not found', 404);
|
||||
const event = await loadEvent(existing.eventId, db);
|
||||
const data = await cleanRole(event, input, existing, db);
|
||||
if (data.sessionId !== undefined && data.sessionId !== existing.sessionId && await db.volunteerSlot.count({ where: { roleId: existing.id } })) {
|
||||
throw volunteerError('People have signed up for this role, so it can\'t move to another session — add a new role there instead.');
|
||||
}
|
||||
return db.servingRole.update({ where: { id: existing.id }, data });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a role. Refused once someone has checked in for it (it counts for hours served).
|
||||
* Returns the volunteers who were on it, so they can be told.
|
||||
*/
|
||||
async function deleteRole(roleId, db = prisma) {
|
||||
const role = await db.servingRole.findUnique({ where: { id: String(roleId || '') }, include: { slots: { include: { user: { select: USER_SELECT } } } } });
|
||||
if (!role) throw volunteerError('Role not found', 404);
|
||||
if (role.slots.some(s => s.checkedInAt)) throw volunteerError('Someone has already checked in for this role, so it stays on the roster (it counts for hours served).');
|
||||
const event = await loadEvent(role.eventId, db);
|
||||
await db.$transaction(async tx => {
|
||||
for (const s of role.slots) await releasePlace(tx, s);
|
||||
await tx.servingRole.delete({ where: { id: role.id } });
|
||||
});
|
||||
return { role, event, volunteers: role.slots.map(s => s.user) };
|
||||
}
|
||||
|
||||
// ─── Settings ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The event's volunteer settings and the tickets that can be the volunteer ticket. */
|
||||
async function getSettings(eventId, db = prisma) {
|
||||
const event = await loadEvent(eventId, db);
|
||||
const [options, rooms] = await Promise.all([
|
||||
db.eventOption.findMany({ where: { eventId: event.id, isMainTicket: true, sessionMode: 'all' }, select: { id: true, name: true, price: true }, orderBy: { name: 'asc' } }),
|
||||
event.kidsRoomSetId ? db.allocationGroup.findMany({ where: { setId: event.kidsRoomSetId }, select: { id: true, name: true }, orderBy: { order: 'asc' } }) : [],
|
||||
]);
|
||||
return {
|
||||
event: { id: event.id, title: event.title, volunteerOptionId: event.volunteerOptionId },
|
||||
options: options.filter(o => o.id !== event.kidsOptionId),
|
||||
kidsRooms: rooms,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the free volunteer place on or off. `optionId: 'new'` adds a free "Volunteer" main ticket
|
||||
* (staff-only: it never shows publicly); null switches it off (places already given stay).
|
||||
*/
|
||||
async function saveSettings(eventId, { optionId }, db = prisma) {
|
||||
const event = await loadEvent(eventId, db);
|
||||
let next = null;
|
||||
if (optionId === 'new') {
|
||||
next = (await db.eventOption.create({ data: { eventId: event.id, name: 'Volunteer', price: 0, isMainTicket: true, sessionMode: 'all' } })).id;
|
||||
} else if (optionId) {
|
||||
const opt = await db.eventOption.findFirst({ where: { id: String(optionId), eventId: event.id, isMainTicket: true, sessionMode: 'all' } });
|
||||
if (!opt || opt.id === event.kidsOptionId) throw volunteerError('Choose a main ticket of this event that covers the whole event (not the kids ticket).');
|
||||
next = opt.id;
|
||||
}
|
||||
await db.event.update({ where: { id: event.id }, data: { volunteerOptionId: next } });
|
||||
return getSettings(event.id, db);
|
||||
}
|
||||
|
||||
/** The event's check-in QR value (the token is made the first time). */
|
||||
async function checkInQr(eventId, db = prisma) {
|
||||
const event = await loadEvent(eventId, db);
|
||||
let token = event.volunteerCheckInToken;
|
||||
if (!token) {
|
||||
token = crypto.randomBytes(12).toString('hex');
|
||||
await db.event.update({ where: { id: event.id }, data: { volunteerCheckInToken: token } });
|
||||
}
|
||||
return { token, qr: QR_PREFIX + token };
|
||||
}
|
||||
|
||||
// ─── The free volunteer place ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Give a volunteer a free place on the event's volunteer ticket, when that's switched on and they
|
||||
* have no ticket for the event yet. Returns the registration id (shared by their slots), or null.
|
||||
*/
|
||||
async function givePlace(tx, event, userId) {
|
||||
if (!event.volunteerOptionId) return null;
|
||||
const shared = await tx.volunteerSlot.findFirst({ where: { userId, registrationId: { not: null }, role: { eventId: event.id }, registration: { status: { not: 'cancelled' } } }, select: { registrationId: true } });
|
||||
if (shared) return shared.registrationId;
|
||||
const hasTicket = await tx.ticket.count({ where: { eventId: event.id, OR: [{ userId }, { holderUserId: userId }], registrationOption: { registration: { status: { not: 'cancelled' } } } } });
|
||||
if (hasTicket) return null;
|
||||
const registration = await tx.registration.create({ data: { userId, eventId: event.id, status: 'paid' } });
|
||||
const line = await tx.registrationOption.create({ data: { registrationId: registration.id, eventOptionId: event.volunteerOptionId, quantity: 1, priceSnapshot: 0 } });
|
||||
await tx.registrationOptionTranche.create({ data: { registrationOptionId: line.id, quantity: 1, priceSnapshot: 0 } });
|
||||
await tx.ticket.create({ data: { id: uuidv4(), qrCode: uuidv4(), registrationOptionId: line.id, userId, eventId: event.id, quantity: 1, emailSent: true } });
|
||||
return registration.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a slot goes (withdrawn, removed, swapped away): cancel its free place if no other slot of
|
||||
* the person at the event still uses it and the ticket was never scanned.
|
||||
*/
|
||||
async function releasePlace(tx, slot) {
|
||||
if (!slot.registrationId) return;
|
||||
const others = await tx.volunteerSlot.count({ where: { registrationId: slot.registrationId, id: { not: slot.id }, userId: slot.userId } });
|
||||
if (others) return;
|
||||
const scanned = await tx.ticketUsage.count({ where: { ticket: { registrationOption: { registrationId: slot.registrationId } } } });
|
||||
if (scanned) return;
|
||||
await tx.registration.updateMany({ where: { id: slot.registrationId, status: { not: 'cancelled' } }, data: { status: 'cancelled' } });
|
||||
}
|
||||
|
||||
// ─── Signing up ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadRole(roleId, db = prisma) {
|
||||
const role = await db.servingRole.findUnique({ where: { id: String(roleId || '') }, include: { session: true, slots: { select: { id: true, userId: true } } } });
|
||||
if (!role) throw volunteerError('Role not found', 404);
|
||||
return role;
|
||||
}
|
||||
|
||||
/** Refuse a role whose shift is over, a cancelled session, or an inactive event. */
|
||||
function assertOpen(role, event, now = new Date()) {
|
||||
if (!event.isActive) throw volunteerError('This event isn\'t open any more.');
|
||||
if (role.session?.cancelledAt) throw volunteerError('That session was cancelled.');
|
||||
if (shiftOf(role, event).endsAt <= new Date(now)) throw volunteerError('This shift is already over.');
|
||||
}
|
||||
|
||||
/** Refuse when the person serves in another role at the same time. */
|
||||
async function assertFree(tx, event, role, userId) {
|
||||
const shift = shiftOf(role, event);
|
||||
const theirs = await tx.volunteerSlot.findMany({ where: { userId, roleId: { not: role.id }, role: { eventId: event.id } }, include: { role: { include: { session: true } } } });
|
||||
const clash = theirs.find(s => overlaps(shiftOf(s.role, event), shift));
|
||||
if (clash) throw volunteerError(`${clash.role.name} (${shiftText(shiftOf(clash.role, event))}) is at the same time.`, 409, 'SHIFT_CLASH');
|
||||
}
|
||||
|
||||
/**
|
||||
* Put someone on a role: they signed up (`assignedById` null — refused when the role is full), or
|
||||
* a supervisor assigned them (allowed beyond the number needed).
|
||||
*/
|
||||
async function signUp({ roleId, userId, assignedById = null, now = new Date() }, db = prisma) {
|
||||
const role = await loadRole(roleId, db);
|
||||
const event = await loadEvent(role.eventId, db);
|
||||
assertOpen(role, event, now);
|
||||
if (role.slots.some(s => s.userId === userId)) throw volunteerError(assignedById ? 'This person is already on this role.' : 'You\'re already serving in this role.', 409);
|
||||
const slot = await db.$transaction(async tx => {
|
||||
if (!assignedById && await tx.volunteerSlot.count({ where: { roleId: role.id } }) >= role.needed) throw volunteerError('This role is full — thank you for offering!', 409, 'ROLE_FULL');
|
||||
await assertFree(tx, event, role, userId);
|
||||
const registrationId = await givePlace(tx, event, userId);
|
||||
return tx.volunteerSlot.create({ data: { roleId: role.id, userId, assignedById, registrationId } });
|
||||
});
|
||||
return { slot, role, event };
|
||||
}
|
||||
|
||||
async function loadSlot(slotId, db = prisma) {
|
||||
const slot = await db.volunteerSlot.findUnique({ where: { id: String(slotId || '') }, include: { user: { select: USER_SELECT }, role: { include: { session: true } } } });
|
||||
if (!slot) throw volunteerError('Not found', 404);
|
||||
return slot;
|
||||
}
|
||||
|
||||
/** Take someone off a role (they withdrew, or a supervisor removed them). Not after check-in. */
|
||||
async function leave({ slotId, userId = null }, db = prisma) {
|
||||
const slot = await loadSlot(slotId, db);
|
||||
if (userId && slot.userId !== userId) throw volunteerError('Not found', 404);
|
||||
if (slot.checkedInAt) throw volunteerError(`${userId ? 'You\'ve' : `${slot.user.name} has`} already checked in for this shift.`);
|
||||
const event = await loadEvent(slot.role.eventId, db);
|
||||
if (userId && shiftOf(slot.role, event).endsAt <= new Date()) throw volunteerError('This shift is already over.');
|
||||
await db.$transaction(async tx => {
|
||||
await tx.volunteerSwap.updateMany({ where: { slotId: slot.id, status: 'open' }, data: { status: 'cancelled', resolvedAt: new Date() } });
|
||||
await releasePlace(tx, slot);
|
||||
await tx.volunteerSlot.delete({ where: { id: slot.id } });
|
||||
});
|
||||
return { slot, event };
|
||||
}
|
||||
|
||||
// ─── Swaps ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** "Can't make it": ask for someone to take over the slot. One open request per slot. */
|
||||
async function requestSwap({ slotId, userId, note, now = new Date() }, db = prisma) {
|
||||
const slot = await loadSlot(slotId, db);
|
||||
if (slot.userId !== userId) throw volunteerError('Not found', 404);
|
||||
const event = await loadEvent(slot.role.eventId, db);
|
||||
assertOpen(slot.role, event, now);
|
||||
if (slot.checkedInAt) throw volunteerError('You\'ve already checked in for this shift.');
|
||||
const open = await db.volunteerSwap.findFirst({ where: { slotId: slot.id, status: 'open' } });
|
||||
if (open) return { swap: open, slot, event, created: false };
|
||||
const swap = await db.volunteerSwap.create({ data: { slotId: slot.id, roleId: slot.roleId, fromUserId: userId, note: cleanText(note, 300) || null } });
|
||||
return { swap, slot, event, created: true };
|
||||
}
|
||||
|
||||
async function cancelSwap({ swapId, userId }, db = prisma) {
|
||||
const out = await db.volunteerSwap.updateMany({ where: { id: String(swapId || ''), fromUserId: userId, status: 'open' }, data: { status: 'cancelled', resolvedAt: new Date() } });
|
||||
if (!out.count) throw volunteerError('Not found', 404);
|
||||
return { cancelled: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Take over a slot someone can't make. Happens at once: the slot moves to the person accepting
|
||||
* (their reminder and free place are theirs now). Returns { swap, slot, from, to, event }.
|
||||
*/
|
||||
async function acceptSwap({ swapId, userId, now = new Date() }, db = prisma) {
|
||||
const swap = await db.volunteerSwap.findUnique({ where: { id: String(swapId || '') } });
|
||||
if (!swap || swap.status !== 'open' || !swap.slotId) throw volunteerError('Someone has already taken this place, or it isn\'t needed any more.', 409, 'SWAP_CLOSED');
|
||||
if (swap.fromUserId === userId) throw volunteerError('This is your own request — cancel it instead.');
|
||||
const slot = await loadSlot(swap.slotId, db);
|
||||
const event = await loadEvent(slot.role.eventId, db);
|
||||
assertOpen(slot.role, event, now);
|
||||
await db.$transaction(async tx => {
|
||||
// Claim the request first, so two people accepting at once can't both take it.
|
||||
const claimed = await tx.volunteerSwap.updateMany({ where: { id: swap.id, status: 'open' }, data: { status: 'accepted', toUserId: userId, resolvedAt: new Date(now) } });
|
||||
if (!claimed.count) throw volunteerError('Someone has just taken this place.', 409, 'SWAP_CLOSED');
|
||||
if (await tx.volunteerSlot.count({ where: { roleId: slot.roleId, userId } })) throw volunteerError('You\'re already serving in this role.', 409);
|
||||
await assertFree(tx, event, slot.role, userId);
|
||||
await releasePlace(tx, slot);
|
||||
const registrationId = await givePlace(tx, event, userId);
|
||||
await tx.volunteerSlot.update({ where: { id: slot.id }, data: { userId, assignedById: null, registrationId, remindedAt: null } });
|
||||
});
|
||||
const to = await db.user.findUnique({ where: { id: userId }, select: USER_SELECT });
|
||||
return { swap, slot, from: slot.user, to, event };
|
||||
}
|
||||
|
||||
/** Open cover requests the person could take (not their own, not a role they're in, not over). */
|
||||
async function openSwaps({ userId, now = new Date() }, db = prisma) {
|
||||
const swaps = await db.volunteerSwap.findMany({
|
||||
where: { status: 'open', fromUserId: { not: userId }, role: { event: { isActive: true, endDate: { gte: now } }, slots: { none: { userId } } } },
|
||||
include: { role: { include: { session: true, event: { select: EVENT_SELECT } } }, fromUser: { select: { name: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 50,
|
||||
});
|
||||
return swaps
|
||||
.map(w => ({ w, shift: shiftOf(w.role, w.role.event) }))
|
||||
.filter(({ shift }) => shift.endsAt > new Date(now))
|
||||
.map(({ w, shift }) => ({
|
||||
swapId: w.id, note: w.note || '', from: (w.fromUser?.name || '').split(' ')[0] || null,
|
||||
role: { id: w.role.id, name: w.role.name, sessionLabel: w.role.session ? sessionsUtil.sessionLabel(w.role.session, w.role.event.sessions) : null },
|
||||
event: { id: w.role.event.id, title: w.role.event.title },
|
||||
shift: { startsAt: shift.startsAt, endsAt: shift.endsAt, text: shiftText(shift) },
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Check-in ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Put a checked-in volunteer of a kids-room role on duty in that room for the occasion. */
|
||||
async function startKidsDuty(tx, event, role, userId, now) {
|
||||
if (!role.kidsRoomId || !event.kidsCheckIn || !event.kidsRoomSetId) return false;
|
||||
const room = await tx.allocationGroup.findFirst({ where: { id: role.kidsRoomId, setId: event.kidsRoomSetId }, select: { id: true } });
|
||||
if (!room) return false;
|
||||
const occasion = kidsOccasion(role, now);
|
||||
const already = await tx.kidsRoomDuty.findFirst({ where: { userId, occasion, roomId: room.id, endedAt: null } });
|
||||
if (already) return true;
|
||||
// One room at a time, as with "I'm on duty".
|
||||
await tx.kidsRoomDuty.updateMany({ where: { userId, occasion, endedAt: null }, data: { endedAt: new Date(now) } });
|
||||
await tx.kidsRoomDuty.create({ data: { roomId: room.id, occasion, userId, fromRoster: true, startedAt: new Date(now) } });
|
||||
return true;
|
||||
}
|
||||
|
||||
async function markCheckedIn(tx, event, slot, { by, method, now }) {
|
||||
await tx.volunteerSlot.update({ where: { id: slot.id }, data: { checkedInAt: new Date(now), checkedInById: by, checkInMethod: method } });
|
||||
return startKidsDuty(tx, event, slot.role, slot.userId, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* A volunteer scanned the event's check-in QR code: check in every slot of theirs whose shift is
|
||||
* on today. Returns { event, checkedIn: [{ role, kidsRoom }], already }.
|
||||
*/
|
||||
async function checkInWithQr({ qr, userId, now = new Date() }, db = prisma) {
|
||||
const token = String(qr || '').trim().replace(new RegExp(`^${QR_PREFIX}`, 'i'), '');
|
||||
const found = token ? await db.event.findUnique({ where: { volunteerCheckInToken: token }, select: { id: true } }) : null;
|
||||
if (!found) throw volunteerError('That isn\'t a volunteer check-in code from this site.', 404, 'BAD_QR');
|
||||
const event = await loadEvent(found.id, db);
|
||||
const slots = await db.volunteerSlot.findMany({ where: { userId, role: { eventId: event.id } }, include: { role: { include: { session: true, kidsRoom: { select: { name: true } } } } } });
|
||||
if (!slots.length) throw volunteerError(`You're not on the volunteer roster for ${event.title}.`, 404, 'NOT_ROSTERED');
|
||||
const today = slots.filter(s => !s.role.session?.cancelledAt && checkInOpen(shiftOf(s.role, event), now));
|
||||
if (!today.length) {
|
||||
const next = slots.map(s => shiftOf(s.role, event)).filter(sh => sh.endsAt > new Date(now)).sort((a, b) => a.startsAt - b.startsAt)[0];
|
||||
throw volunteerError(next ? `Check-in opens on the day of your shift (${shiftText(next)}).` : 'Your shifts at this event are over.', 400, 'NOT_TODAY');
|
||||
}
|
||||
const checkedIn = [];
|
||||
const already = [];
|
||||
await db.$transaction(async tx => {
|
||||
for (const s of today) {
|
||||
if (s.checkedInAt) { already.push(s.role.name); continue; }
|
||||
const onDuty = await markCheckedIn(tx, event, s, { by: userId, method: 'qr', now });
|
||||
checkedIn.push({ role: s.role.name, kidsRoom: onDuty ? s.role.kidsRoom?.name || null : null });
|
||||
}
|
||||
});
|
||||
return { event: { id: event.id, title: event.title }, checkedIn, already };
|
||||
}
|
||||
|
||||
/** Staff tick a volunteer in (or undo it, which also ends their rostered kids-room duty). */
|
||||
async function checkInManual({ slotId, staffUserId, undo = false, now = new Date() }, db = prisma) {
|
||||
const slot = await loadSlot(slotId, db);
|
||||
const event = await loadEvent(slot.role.eventId, db);
|
||||
if (undo) {
|
||||
if (!slot.checkedInAt) return { slot, changed: false };
|
||||
await db.$transaction(async tx => {
|
||||
await tx.volunteerSlot.update({ where: { id: slot.id }, data: { checkedInAt: null, checkedInById: null, checkInMethod: null } });
|
||||
if (slot.role.kidsRoomId) await tx.kidsRoomDuty.updateMany({ where: { userId: slot.userId, roomId: slot.role.kidsRoomId, fromRoster: true, endedAt: null }, data: { endedAt: new Date(now) } });
|
||||
});
|
||||
return { slot, changed: true };
|
||||
}
|
||||
if (slot.checkedInAt) return { slot, changed: false };
|
||||
if (!checkInOpen(shiftOf(slot.role, event), now) && new Date(now) < shiftOf(slot.role, event).startsAt) {
|
||||
throw volunteerError(`${slot.user.name} can be checked in on the day of the shift (${shiftText(shiftOf(slot.role, event))}).`);
|
||||
}
|
||||
await db.$transaction(tx => markCheckedIn(tx, event, slot, { by: staffUserId, method: 'manual', now }));
|
||||
return { slot, changed: true };
|
||||
}
|
||||
|
||||
/** For the kids board: who is rostered in each room for an occasion, and whether they've checked in. */
|
||||
async function rosteredForRooms(event, occasion, db = prisma) {
|
||||
if (!event.kidsRoomSetId) return {};
|
||||
const roles = await db.servingRole.findMany({
|
||||
where: { eventId: event.id, kidsRoom: { setId: event.kidsRoomSetId }, ...(event.scheduleType === 'sessions' ? { sessionId: occasion } : {}) },
|
||||
include: { session: true, slots: { include: { user: { select: { id: true, name: true } } } } },
|
||||
});
|
||||
const out = {};
|
||||
for (const r of roles) {
|
||||
// Single-date events: only roles whose shift is on that day.
|
||||
if (event.scheduleType !== 'sessions') {
|
||||
const shift = shiftOf(r, event);
|
||||
if (occasion < sessionsUtil.dayKey(shift.startsAt) || occasion > sessionsUtil.dayKey(shift.endsAt)) continue;
|
||||
}
|
||||
for (const s of r.slots) {
|
||||
(out[r.kidsRoomId] ||= []).push({ userId: s.user.id, name: s.user.name, checkedIn: !!s.checkedInAt });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── My serving ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** The person's upcoming (and the last week's) slots, with open swap requests. */
|
||||
async function mySlots({ userId, now = new Date() }, db = prisma) {
|
||||
const since = new Date(new Date(now).getTime() - 7 * 24 * HOUR);
|
||||
const slots = await db.volunteerSlot.findMany({
|
||||
where: { userId, role: { event: { endDate: { gte: since } } } },
|
||||
include: { role: { include: { session: true, kidsRoom: { select: { name: true } }, event: { select: EVENT_SELECT } } }, swaps: { where: { status: 'open' } } },
|
||||
});
|
||||
return slots
|
||||
.map(s => {
|
||||
const shift = shiftOf(s.role, s.role.event);
|
||||
return {
|
||||
slotId: s.id,
|
||||
role: { id: s.role.id, name: s.role.name, description: s.role.description || '', sessionLabel: s.role.session ? sessionsUtil.sessionLabel(s.role.session, s.role.event.sessions) : null, kidsRoom: s.role.kidsRoom?.name || null },
|
||||
event: { id: s.role.event.id, title: s.role.event.title, location: s.role.event.location },
|
||||
shift: { startsAt: shift.startsAt, endsAt: shift.endsAt, text: shiftText(shift), hours: shiftHours(shift) },
|
||||
checkedInAt: s.checkedInAt,
|
||||
canCheckIn: !s.checkedInAt && checkInOpen(shift, now),
|
||||
past: shift.endsAt < new Date(now),
|
||||
swapId: s.swaps[0]?.id || null,
|
||||
hasTicket: !!s.registrationId,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => new Date(a.shift.startsAt) - new Date(b.shift.startsAt));
|
||||
}
|
||||
|
||||
/** Upcoming events with serving roles (for "Serve at an event"). */
|
||||
async function eventsWithRoles({ now = new Date(), where = {} } = {}, db = prisma) {
|
||||
return db.event.findMany({
|
||||
where: { AND: [{ isActive: true, endDate: { gte: now }, servingRoles: { some: {} } }, where] },
|
||||
select: { id: true, title: true, startDate: true, endDate: true, location: true, campusId: true, isHidden: true, _count: { select: { servingRoles: true } } },
|
||||
orderBy: { startDate: 'asc' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Reports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hours served per person over the events matching `eventWhere` (checked-in shifts only), most
|
||||
* hours first: [{ userId, name, shifts, hours, events }].
|
||||
*/
|
||||
async function hoursServed({ eventWhere = {}, from = null, to = null } = {}, db = prisma) {
|
||||
const slots = await db.volunteerSlot.findMany({
|
||||
where: { checkedInAt: { not: null, ...(from ? { gte: new Date(from) } : {}), ...(to ? { lte: new Date(to) } : {}) }, role: { event: eventWhere } },
|
||||
include: { user: { select: { id: true, name: true } }, role: { include: { session: true, event: { select: { id: true, startDate: true, endDate: true, sessions: true } } } } },
|
||||
});
|
||||
const byUser = new Map();
|
||||
for (const s of slots) {
|
||||
const row = byUser.get(s.userId) || { userId: s.userId, name: s.user.name, shifts: 0, hours: 0, events: new Set() };
|
||||
row.shifts += 1;
|
||||
row.hours += shiftHours(shiftOf(s.role, s.role.event));
|
||||
row.events.add(s.role.event.id);
|
||||
byUser.set(s.userId, row);
|
||||
}
|
||||
return [...byUser.values()].map(r => ({ ...r, events: r.events.size })).sort((a, b) => b.hours - a.hours || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// ─── Messages ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function frontendUrl() {
|
||||
return String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
const servingUrl = () => `${frontendUrl()}/dashboard/user/serving`;
|
||||
|
||||
/** Send a text to a volunteer the way they like it (WhatsApp, email, or whichever works). Fire-and-forget. */
|
||||
function tell(user, { subject, heading, text }) {
|
||||
if (!user) return Promise.resolve([]);
|
||||
return require('./kids').messageParent(user, { subject, heading, text }).catch(() => []);
|
||||
}
|
||||
|
||||
/** Email the event's notify recipients (or its creator) about a roster change. */
|
||||
async function tellOrganisers(eventId, { subject, text }, db = prisma) {
|
||||
const event = await db.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { createdBy: { select: { email: true } }, notifyRecipients: { select: { email: true } } },
|
||||
});
|
||||
const to = require('./notifications').getEventNotifyEmails(event);
|
||||
if (!to.length) return;
|
||||
const { sendMail, buildPrivacyEmail } = require('./email');
|
||||
await sendMail({ to: to.join(','), subject, ...buildPrivacyEmail({ name: 'team', heading: subject, paragraphs: [text, `Roster: ${frontendUrl()}/dashboard/supervisor/volunteers?event=${eventId}`] }) }).catch(() => {});
|
||||
}
|
||||
|
||||
function placeText(role, event) {
|
||||
return `${role.name} at ${event.title}${role.session ? ` (${sessionsUtil.sessionLabel(role.session, event.sessions)})` : ''}, ${shiftText(shiftOf(role, event))}`;
|
||||
}
|
||||
|
||||
function signedUpMessages({ user, role, event, by = null }) {
|
||||
const text = by
|
||||
? `${by} has put you on the volunteer roster: ${placeText(role, event)}. Thank you for serving! If you can't make it, ask for cover in My account → Serving (${servingUrl()}).`
|
||||
: `Thank you for volunteering: ${placeText(role, event)}. You'll get a reminder the day before. If you can't make it, ask for cover in My account → Serving (${servingUrl()}).`;
|
||||
return tell(user, { subject: `Serving: ${role.name} — ${event.title}`, heading: 'You\'re on the roster', text });
|
||||
}
|
||||
|
||||
/** The cover request goes to the event's other volunteers (not the ones already in this role). */
|
||||
async function sendSwapRequest({ swap, slot, event }, db = prisma) {
|
||||
const others = await db.volunteerSlot.findMany({
|
||||
where: { role: { eventId: event.id }, userId: { not: slot.userId }, NOT: { roleId: slot.roleId } },
|
||||
distinct: ['userId'],
|
||||
include: { user: { select: USER_SELECT } },
|
||||
take: 200,
|
||||
});
|
||||
const inRole = new Set((await db.volunteerSlot.findMany({ where: { roleId: slot.roleId }, select: { userId: true } })).map(s => s.userId));
|
||||
const first = (slot.user.name || '').split(' ')[0] || 'A volunteer';
|
||||
const text = `${first} can't make it and is looking for someone to take over: ${placeText(slot.role, event)}.${swap.note ? ` "${swap.note}"` : ''} If you can, take it over in My account → Serving (${servingUrl()}) — the first to accept gets it.`;
|
||||
for (const o of others) {
|
||||
if (inRole.has(o.userId)) continue;
|
||||
await tell(o.user, { subject: `Cover needed: ${slot.role.name} — ${event.title}`, heading: 'Can you take over?', text });
|
||||
}
|
||||
return others.length;
|
||||
}
|
||||
|
||||
async function sendSwapAccepted({ slot, from, to, event }) {
|
||||
const what = placeText(slot.role, event);
|
||||
await tell(from, { subject: `${to.name} is taking over — ${event.title}`, heading: 'Your place is covered', text: `${to.name} has taken over ${what}. You're off the roster for it — thank you for finding cover.` });
|
||||
await tell(to, { subject: `Serving: ${slot.role.name} — ${event.title}`, heading: 'You\'re on the roster', text: `Thank you for taking over from ${from.name}: ${what}. You'll get a reminder the day before.` });
|
||||
await tellOrganisers(event.id, { subject: `Roster swap: ${slot.role.name} — ${event.title}`, text: `${to.name} has taken over from ${from.name}: ${what}.` });
|
||||
}
|
||||
|
||||
async function sendLeft({ slot, event, byStaff = null }) {
|
||||
const what = placeText(slot.role, event);
|
||||
if (byStaff) {
|
||||
await tell(slot.user, { subject: `Roster change — ${event.title}`, heading: 'Roster change', text: `${byStaff} has taken you off the roster for ${what}. Thank you for being willing to serve.` });
|
||||
} else {
|
||||
await tellOrganisers(event.id, { subject: `Volunteer withdrew: ${slot.role.name} — ${event.title}`, text: `${slot.user.name} has withdrawn from ${what}.` });
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRoleDeleted({ role, event, volunteers }) {
|
||||
for (const u of volunteers) {
|
||||
await tell(u, { subject: `Roster change — ${event.title}`, heading: 'Roster change', text: `The ${role.name} role at ${event.title} is no longer needed, so you're off the roster for it. Thank you for being willing to serve.` });
|
||||
}
|
||||
}
|
||||
|
||||
/** Reminders for shifts starting within the next 24 hours. Returns { sent }. */
|
||||
async function runReminderJob(now = new Date(), db = prisma) {
|
||||
const slots = await db.volunteerSlot.findMany({
|
||||
where: { remindedAt: null, checkedInAt: null, role: { event: { isActive: true, endDate: { gte: now }, startDate: { lte: new Date(new Date(now).getTime() + 31 * 24 * HOUR) } } } },
|
||||
include: { user: { select: USER_SELECT }, role: { include: { session: true, event: { select: EVENT_SELECT } } } },
|
||||
take: 2000,
|
||||
});
|
||||
let sent = 0;
|
||||
for (const s of slots) {
|
||||
if (s.role.session?.cancelledAt) continue;
|
||||
const shift = shiftOf(s.role, s.role.event);
|
||||
if (!reminderDue(s, shift, now)) continue;
|
||||
// Claim it first so a second backend process doesn't send it again.
|
||||
const claimed = await db.volunteerSlot.updateMany({ where: { id: s.id, remindedAt: null }, data: { remindedAt: new Date(now) } });
|
||||
if (!claimed.count) continue;
|
||||
const ev = s.role.event;
|
||||
const place = ev.location ? ` at ${ev.location}` : '';
|
||||
await tell(s.user, {
|
||||
subject: `Reminder: serving ${s.role.name} — ${ev.title}`,
|
||||
heading: 'See you soon',
|
||||
text: `A reminder that you're serving: ${placeText(s.role, ev)}${place}. When you arrive, scan the volunteer check-in QR code with your phone. Can't make it? Ask for cover in My account → Serving (${servingUrl()}).`,
|
||||
});
|
||||
sent += 1;
|
||||
}
|
||||
return { sent };
|
||||
}
|
||||
|
||||
// ─── Privacy ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** For "Download my data". */
|
||||
async function exportVolunteering(userId, db = prisma) {
|
||||
const slots = await db.volunteerSlot.findMany({
|
||||
where: { userId },
|
||||
include: { role: { include: { session: true, event: { select: { title: true, startDate: true, endDate: true, sessions: true } } } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return slots.map(s => {
|
||||
const shift = shiftOf(s.role, s.role.event);
|
||||
return { event: s.role.event.title, role: s.role.name, from: shift.startsAt, to: shift.endsAt, signedUp: s.createdAt, assigned: !!s.assignedById, checkedIn: s.checkedInAt };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* For anonymiseUser: shifts still to come are given up (open places, free tickets released);
|
||||
* past ones stay for the hours-served totals, under the anonymised account. Swap notes go.
|
||||
*/
|
||||
async function forgetVolunteer(tx, userId, now = new Date()) {
|
||||
const slots = await tx.volunteerSlot.findMany({ where: { userId, checkedInAt: null }, include: { role: { include: { session: true, event: { select: { startDate: true, endDate: true, sessions: true } } } } } });
|
||||
let removed = 0;
|
||||
for (const s of slots) {
|
||||
if (shiftOf(s.role, s.role.event).endsAt <= new Date(now)) continue;
|
||||
await tx.volunteerSwap.updateMany({ where: { slotId: s.id, status: 'open' }, data: { status: 'cancelled', resolvedAt: new Date(now) } });
|
||||
await releasePlace(tx, s);
|
||||
await tx.volunteerSlot.delete({ where: { id: s.id } });
|
||||
removed += 1;
|
||||
}
|
||||
await tx.volunteerSwap.updateMany({ where: { OR: [{ fromUserId: userId }, { toUserId: userId }], note: { not: null } }, data: { note: null } });
|
||||
return removed;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
QR_PREFIX, MAX_NEEDED, volunteerError,
|
||||
shiftOf, overlaps, shiftHours, fillOf, checkInOpen, reminderDue, kidsOccasion, reservedOptionIds, publicOptions, shiftText,
|
||||
loadEvent, roleView, roster, createRole, updateRole, deleteRole, getSettings, saveSettings, checkInQr,
|
||||
givePlace, releasePlace, signUp, leave, requestSwap, cancelSwap, acceptSwap, openSwaps,
|
||||
checkInWithQr, checkInManual, rosteredForRooms, mySlots, eventsWithRoles, hoursServed,
|
||||
signedUpMessages, sendSwapRequest, sendSwapAccepted, sendLeft, sendRoleDeleted, runReminderJob,
|
||||
exportVolunteering, forgetVolunteer,
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
const cs = require('../src/utils/campusScope');
|
||||
|
||||
const north = { campusIds: ['north'], names: ['North'] };
|
||||
|
||||
describe('campus scope rules', () => {
|
||||
test('unscoped sees and manages everything', () => {
|
||||
expect(cs.canView(null, 'south')).toBe(true);
|
||||
expect(cs.canManage(null, null)).toBe(true);
|
||||
expect(cs.eventWhere(null)).toEqual({});
|
||||
});
|
||||
|
||||
test('scoped: own campus and church-wide events are visible, other campuses are not', () => {
|
||||
expect(cs.canView(north, 'north')).toBe(true);
|
||||
expect(cs.canView(north, null)).toBe(true);
|
||||
expect(cs.canView(north, 'south')).toBe(false);
|
||||
});
|
||||
|
||||
test('scoped: only own campus events can be managed — not church-wide ones', () => {
|
||||
expect(cs.canManage(north, 'north')).toBe(true);
|
||||
expect(cs.canManage(north, null)).toBe(false);
|
||||
expect(cs.canManage(north, 'south')).toBe(false);
|
||||
});
|
||||
|
||||
test('list filters, also through relations', () => {
|
||||
const ev = { OR: [{ campusId: { in: ['north'] } }, { campusId: null }] };
|
||||
expect(cs.eventWhere(north)).toEqual(ev);
|
||||
expect(cs.manageWhere(north)).toEqual({ campusId: { in: ['north'] } });
|
||||
expect(cs.relWhere(north, 'registration.event')).toEqual({ registration: { event: ev } });
|
||||
expect(cs.relWhere(null, 'event')).toEqual({});
|
||||
});
|
||||
|
||||
test('withScope never overwrites the other filter', () => {
|
||||
const own = { OR: [{ a: 1 }, { b: 2 }] };
|
||||
const scope = cs.eventWhere(north);
|
||||
expect(cs.withScope(own, scope)).toEqual({ AND: [own, scope] });
|
||||
expect(cs.withScope(own, {})).toBe(own);
|
||||
expect(cs.withScope({}, scope)).toBe(scope);
|
||||
});
|
||||
|
||||
test('coupons: only when limited to events, all at own campuses', () => {
|
||||
expect(cs.couponAllowed(null, [])).toBe(true);
|
||||
expect(cs.couponAllowed(north, [])).toBe(false); // every event
|
||||
expect(cs.couponAllowed(north, ['north'])).toBe(true);
|
||||
expect(cs.couponAllowed(north, ['north', null])).toBe(false); // a church-wide event
|
||||
expect(cs.couponAllowed(north, ['north', 'south'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('campus scope middleware', () => {
|
||||
// The scope is preset on the request (scopeFor caches it there) and the resolvers are stubbed,
|
||||
// so these run without a database.
|
||||
const saved = { ...cs.RESOLVERS };
|
||||
const events = {
|
||||
n1: { id: 'n1', campusId: 'north', campusName: 'North', owners: ['u-owner'] },
|
||||
s1: { id: 's1', campusId: 'south', campusName: 'South', owners: ['u-owner'] },
|
||||
all: { id: 'all', campusId: null, campusName: null, owners: [] },
|
||||
};
|
||||
beforeAll(() => {
|
||||
for (const k of ['event', 'registration', 'ticketQr', 'payment']) cs.RESOLVERS[k] = async id => events[id] || null;
|
||||
});
|
||||
afterAll(() => Object.assign(cs.RESOLVERS, saved));
|
||||
|
||||
const run = async (mw, { scope = north, params = {}, body = {}, userId = 'u-staff' } = {}) => {
|
||||
const req = { _campusScope: scope, params, body, user: { id: userId, role: 'supervisor' } };
|
||||
const out = { status: 200, body: null, next: false };
|
||||
const res = { status(c) { out.status = c; return this; }, json(b) { out.body = b; return this; } };
|
||||
await mw(req, res, () => { out.next = true; });
|
||||
return out;
|
||||
};
|
||||
|
||||
test('scopeEvent: own campus and church-wide pass, another campus is "not found"', async () => {
|
||||
const mw = cs.scopeEvent();
|
||||
expect((await run(mw, { params: { id: 'n1' } })).next).toBe(true);
|
||||
expect((await run(mw, { params: { id: 'all' } })).next).toBe(true);
|
||||
const other = await run(mw, { params: { id: 's1' } });
|
||||
expect(other.next).toBe(false);
|
||||
expect(other.status).toBe(404);
|
||||
});
|
||||
|
||||
test('scopeEvent: unscoped always passes; unknown records pass to the controller', async () => {
|
||||
expect((await run(cs.scopeEvent(), { scope: null, params: { id: 's1' } })).next).toBe(true);
|
||||
expect((await run(cs.scopeEvent(), { params: { id: 'nope' } })).next).toBe(true);
|
||||
});
|
||||
|
||||
test("scopeEvent need 'manage': church-wide events are refused with a reason", async () => {
|
||||
const mw = cs.scopeEvent({ need: 'manage' });
|
||||
expect((await run(mw, { params: { id: 'n1' } })).next).toBe(true);
|
||||
const all = await run(mw, { params: { id: 'all' } });
|
||||
expect(all.status).toBe(403);
|
||||
expect(all.body.code).toBe('CHURCH_WIDE_EVENT');
|
||||
});
|
||||
|
||||
test('scopeEvent ownerOk: the owner of another campus\'s record still gets through', async () => {
|
||||
const mw = cs.scopeEvent({ resolve: 'registration', ownerOk: true });
|
||||
expect((await run(mw, { params: { id: 's1' }, userId: 'u-owner' })).next).toBe(true);
|
||||
expect((await run(mw, { params: { id: 's1' } })).status).toBe(404);
|
||||
});
|
||||
|
||||
test('the scanner names the campus of another campus\'s ticket', async () => {
|
||||
const mw = cs.scopeEvent({ resolve: 'ticketQr', from: r => r.params.qrCode, nameCampus: true });
|
||||
const out = await run(mw, { params: { qrCode: 's1' } });
|
||||
expect(out.status).toBe(403);
|
||||
expect(out.body).toEqual({ code: 'OTHER_CAMPUS', message: 'This ticket is for an event at South.' });
|
||||
});
|
||||
|
||||
test('scopeBody: every target and every id in a list is checked', async () => {
|
||||
const mw = cs.scopeBody([['registrationId', 'registration'], ['ticketIds', 'ticket']]);
|
||||
cs.RESOLVERS.ticket = async id => events[id] || null;
|
||||
expect((await run(mw, { body: { ticketIds: ['n1', 'all'] } })).next).toBe(true);
|
||||
expect((await run(mw, { body: { ticketIds: ['n1', 's1'] } })).status).toBe(404);
|
||||
});
|
||||
|
||||
test("scopeBody orphan 'deny': a payment not linked to any event is unscoped-only", async () => {
|
||||
const mw = cs.scopeBody([['registrationId', 'registration'], ['eventId', 'event']], { orphan: 'deny' });
|
||||
expect((await run(mw, { body: {} })).status).toBe(403);
|
||||
expect((await run(mw, { body: { eventId: 'n1' } })).next).toBe(true);
|
||||
expect((await run(mw, { scope: null, body: {} })).next).toBe(true);
|
||||
});
|
||||
|
||||
test('unscopedOnly refuses scoped users', async () => {
|
||||
expect((await run(cs.unscopedOnly)).status).toBe(403);
|
||||
expect((await run(cs.unscopedOnly, { scope: null })).next).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
// Every staff/supervisor route must say how it handles campus-scoped staff (utils/campusScope.js):
|
||||
// scopeEvent(...), scopeList, unscopedOnly or campusIndependent('reason'). A route that says
|
||||
// nothing would show a campus-limited supervisor every campus's data, so this fails on it.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { staff, supervisor } = require('../src/middleware/authMiddleware');
|
||||
|
||||
const ROUTES_DIR = path.join(__dirname, '..', 'src', 'routes');
|
||||
|
||||
function staffRoutes() {
|
||||
const out = [];
|
||||
for (const file of fs.readdirSync(ROUTES_DIR).filter(f => f.endsWith('.js'))) {
|
||||
const router = require(path.join(ROUTES_DIR, file));
|
||||
for (const layer of router.stack || []) {
|
||||
if (!layer.route) continue;
|
||||
for (const method of Object.keys(layer.route.methods)) {
|
||||
// router.route('/x').get(...).put(...) puts every method's handlers on one route, so the
|
||||
// role is checked per method (admin-only methods are never campus-scoped).
|
||||
const forMethod = layer.route.stack.filter(s => s.method === method || s.method === undefined).map(s => s.handle);
|
||||
if (!forMethod.includes(staff) && !forMethod.includes(supervisor)) continue;
|
||||
const marker = forMethod.find(h => h && h.campusScope);
|
||||
out.push({ file, method: method.toUpperCase(), path: layer.route.path, marker: marker ? marker.campusScope : null });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('campus scope on staff and supervisor routes', () => {
|
||||
const routes = staffRoutes();
|
||||
|
||||
test('finds the staff and supervisor routes', () => {
|
||||
expect(routes.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
test('every one says how it handles campus-scoped staff', () => {
|
||||
const missing = routes.filter(r => !r.marker).map(r => `${r.file} ${r.method} ${r.path}`);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
test('a campus-independent route gives a reason', () => {
|
||||
for (const r of routes.filter(x => x.marker && x.marker.startsWith('independent'))) {
|
||||
expect(r.marker.length).toBeGreaterThan('independent: '.length + 5);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
const { slugify, cleanCampusInput, campusWhere, campusAudienceWhere, campusForEvent } = require('../src/utils/campuses');
|
||||
|
||||
describe('campus web addresses', () => {
|
||||
test.each([
|
||||
['Hope Centre — Henley', 'hope-centre-henley'],
|
||||
[' Vaal Campus ', 'vaal-campus'],
|
||||
['Ēast Rand!!', 'east-rand'],
|
||||
['---', ''],
|
||||
])('%s → %s', (input, slug) => expect(slugify(input)).toBe(slug));
|
||||
});
|
||||
|
||||
describe('campus fields', () => {
|
||||
test('a new campus needs a name; the slug comes from it', () => {
|
||||
expect(cleanCampusInput({}).error).toMatch(/name/);
|
||||
expect(cleanCampusInput({ name: 'Henley Campus' }).data).toMatchObject({ name: 'Henley Campus', slug: 'henley-campus' });
|
||||
});
|
||||
|
||||
test('a chosen slug is cleaned', () => {
|
||||
expect(cleanCampusInput({ name: 'Henley', slug: 'Henley On Klip' }).data.slug).toBe('henley-on-klip');
|
||||
});
|
||||
|
||||
test('bad colour or email is refused; blanks become null', () => {
|
||||
expect(cleanCampusInput({ name: 'A', colour: 'blue' }).error).toMatch(/colour/);
|
||||
expect(cleanCampusInput({ name: 'A', contactEmail: 'nope' }).error).toMatch(/email/);
|
||||
expect(cleanCampusInput({ name: 'A', colour: '#1D4ED8', address: ' ' }).data).toMatchObject({ colour: '#1d4ed8', address: null });
|
||||
});
|
||||
|
||||
test('an update only touches what was sent', () => {
|
||||
expect(cleanCampusInput({ address: '1 Church St' }, { partial: true }).data).toEqual({ address: '1 Church St' });
|
||||
expect(cleanCampusInput({ name: '' }, { partial: true }).error).toMatch(/name/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('campus filters on event lists', () => {
|
||||
test('ignored while campuses are off, or with no filter', () => {
|
||||
expect(campusWhere('c1', { enabled: false })).toEqual({});
|
||||
expect(campusWhere('', { enabled: true })).toEqual({});
|
||||
});
|
||||
|
||||
test('one campus, or only church-wide events', () => {
|
||||
expect(campusWhere('c1', { enabled: true })).toEqual({ campusId: 'c1' });
|
||||
expect(campusWhere('all', { enabled: true })).toEqual({ campusId: null });
|
||||
});
|
||||
|
||||
test('the public list also shows church-wide events at every campus', () => {
|
||||
expect(campusWhere('c1', { enabled: true, withAllCampuses: true })).toEqual({ OR: [{ campusId: 'c1' }, { campusId: null }] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the campus on an event', () => {
|
||||
test('nothing changes while campuses are off or nothing was sent', async () => {
|
||||
expect(await campusForEvent('c1', { enabled: false })).toEqual({ campusId: undefined });
|
||||
expect(await campusForEvent(undefined, { enabled: true })).toEqual({ campusId: undefined });
|
||||
});
|
||||
|
||||
test('blank or "all" means all campuses', async () => {
|
||||
expect(await campusForEvent('', { enabled: true })).toEqual({ campusId: null });
|
||||
expect(await campusForEvent(null, { enabled: true })).toEqual({ campusId: null });
|
||||
expect(await campusForEvent('all', { enabled: true })).toEqual({ campusId: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('everyone at a campus', () => {
|
||||
const now = new Date('2026-10-27T10:00:00Z');
|
||||
|
||||
test('home campus only, active real accounts', () => {
|
||||
const where = campusAudienceWhere('c1', { now });
|
||||
expect(where.isActive).toBe(true);
|
||||
expect(where.OR).toEqual([{ homeCampusId: 'c1' }]);
|
||||
expect(where.NOT).toEqual(expect.arrayContaining([{ email: { endsWith: '@deleted.invalid' } }, { email: { endsWith: '@guest.local' } }]));
|
||||
});
|
||||
|
||||
test('with registrants: registered for that campus\'s events in the last 12 months, not cancelled', () => {
|
||||
const where = campusAudienceWhere('c1', { includeRegistrants: true, now });
|
||||
const reg = where.OR[1].registrations.some;
|
||||
expect(reg.event).toEqual({ campusId: 'c1' });
|
||||
expect(reg.status).toEqual({ not: 'cancelled' });
|
||||
expect(reg.createdAt.gte.toISOString().slice(0, 10)).toBe('2025-10-27');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
jest.mock('../src/config/db', () => ({}));
|
||||
|
||||
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, pendingUpdateRequest } = require('../src/controllers/hubController');
|
||||
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
|
||||
function sign(payload, key = privateKey) {
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
return `hel1.${body}.${crypto.sign(null, Buffer.from(`hel1.${body}`), key).toString('base64url')}`;
|
||||
}
|
||||
|
||||
const NOW = new Date('2026-10-26T10:00:00Z');
|
||||
const base = {
|
||||
v: 1, installId: 'inst_1', status: 'active', graceDays: 14,
|
||||
licenceExpiresAt: '2027-10-31T21:59:59.000Z',
|
||||
issuedAt: '2026-10-26T09:45:00.000Z', validUntil: '2026-11-25T09:45:00.000Z',
|
||||
};
|
||||
|
||||
describe('licence tokens', () => {
|
||||
test('a token the Hub signed verifies; edited or foreign ones do not', () => {
|
||||
expect(verifyToken(sign(base), publicKey)).toMatchObject({ installId: 'inst_1', status: 'active' });
|
||||
const [p, , s] = sign(base).split('.');
|
||||
const edited = `${p}.${Buffer.from(JSON.stringify({ ...base, status: 'active', licenceExpiresAt: '2099-01-01' })).toString('base64url')}.${s}`;
|
||||
expect(verifyToken(edited, publicKey)).toBeNull();
|
||||
const other = crypto.generateKeyPairSync('ed25519');
|
||||
expect(verifyToken(sign(base, other.privateKey), publicKey)).toBeNull();
|
||||
expect(verifyToken('', publicKey)).toBeNull();
|
||||
expect(verifyToken(sign(base), null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('licence states', () => {
|
||||
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', () => {
|
||||
expect(stateFor(base, 'inst_other', NOW).state).toBe('unlicensed');
|
||||
});
|
||||
|
||||
test('active until the earlier of the licence end and validUntil', () => {
|
||||
expect(stateFor(base, 'inst_1', NOW)).toMatchObject({ state: 'active', readOnly: false });
|
||||
expect(stateFor(base, 'inst_1', NOW).endsAt.toISOString()).toBe('2026-11-25T09:45:00.000Z');
|
||||
});
|
||||
|
||||
test('grace after the end date for graceDays, with days left — then read-only', () => {
|
||||
const expired = { ...base, licenceExpiresAt: '2026-10-20T00:00:00.000Z' };
|
||||
expect(stateFor(expired, 'inst_1', NOW)).toMatchObject({ state: 'grace', readOnly: false, daysLeft: 8 });
|
||||
expect(stateFor(expired, 'inst_1', new Date('2026-11-05T00:00:00Z'))).toMatchObject({ state: 'suspended', readOnly: true, expired: true });
|
||||
});
|
||||
|
||||
test('Hub unreachable: still active for 30 days, then grace, then read-only', () => {
|
||||
expect(stateFor({ ...base, licenceExpiresAt: null }, 'inst_1', new Date('2026-11-20T00:00:00Z')).state).toBe('active');
|
||||
expect(stateFor({ ...base, licenceExpiresAt: null }, 'inst_1', new Date('2026-12-01T00:00:00Z')).state).toBe('grace');
|
||||
expect(stateFor({ ...base, licenceExpiresAt: null }, 'inst_1', new Date('2026-12-15T00:00:00Z')).state).toBe('suspended');
|
||||
});
|
||||
|
||||
test('suspended and revoked are read-only straight away', () => {
|
||||
expect(stateFor({ ...base, status: 'suspended' }, 'inst_1', NOW)).toMatchObject({ state: 'suspended', readOnly: true });
|
||||
expect(stateFor({ ...base, status: 'revoked' }, 'inst_1', NOW)).toMatchObject({ state: 'revoked', readOnly: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('heartbeat report', () => {
|
||||
test('income is sent as a band, never an amount', () => {
|
||||
expect(incomeBand(0)).toBe('R0–R1 000');
|
||||
expect(incomeBand(1000)).toBe('R0–R1 000');
|
||||
expect(incomeBand(1000.4)).toBe('R0–R1 000');
|
||||
expect(incomeBand(1001)).toBe('R1 001–R5 000');
|
||||
expect(incomeBand(19999)).toBe('R5 001–R20 000');
|
||||
expect(incomeBand(100000)).toBe('R50 001–R100 000');
|
||||
expect(incomeBand(250000)).toBe('over R100 000');
|
||||
expect(incomeBand(null)).toBe('R0–R1 000');
|
||||
});
|
||||
});
|
||||
|
||||
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'],
|
||||
['POST', '/api/payments'],
|
||||
['POST', '/api/payments/checkout'],
|
||||
['POST', '/api/payments/pay/chk_1'],
|
||||
['POST', '/api/waitlist'],
|
||||
])('%s %s is blocked', (m, p) => expect(isBlocked(m, p)).toBe(true));
|
||||
|
||||
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'],
|
||||
['POST', '/api/hub/connect'],
|
||||
['POST', '/api/users/login'],
|
||||
['POST', '/api/reports/pdf'],
|
||||
])('%s %s keeps working', (m, p) => expect(isBlocked(m, p)).toBe(false));
|
||||
});
|
||||
|
||||
describe('pasting the Hub public key', () => {
|
||||
const { normalisePem } = require('../src/hub/publicKey');
|
||||
const pem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
||||
const base64 = pem.replace(/-----(BEGIN|END) PUBLIC KEY-----|\s/g, '');
|
||||
|
||||
test.each([
|
||||
['proper PEM', pem],
|
||||
['with \\n', pem.replace(/\n/g, '\\n')],
|
||||
['all on one line', `-----BEGIN PUBLIC KEY-----${base64}-----END PUBLIC KEY-----`],
|
||||
['just the base64', base64],
|
||||
])('%s works', (label, input) => {
|
||||
expect(() => crypto.createPublicKey(normalisePem(input))).not.toThrow();
|
||||
expect(verifyToken(sign(base), crypto.createPublicKey(normalisePem(input)))).not.toBeNull();
|
||||
});
|
||||
|
||||
test('empty means none', () => {
|
||||
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"', () => {
|
||||
expect(compareVersions('1.41.0', '1.41.1')).toBeLessThan(0);
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
const kids = require('../src/utils/kids');
|
||||
|
||||
describe('pickup codes', () => {
|
||||
test('are 4 characters from the alphabet without 0/O/1/I', () => {
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const c = kids.randomCode();
|
||||
expect(c).toHaveLength(4);
|
||||
expect(c).toMatch(/^[A-HJ-NP-Z2-9]{4}$/);
|
||||
}
|
||||
});
|
||||
|
||||
test('typed or scanned codes are cleaned up', () => {
|
||||
expect(kids.normaliseCode('k7p2')).toBe('K7P2');
|
||||
expect(kids.normaliseCode(' K7 P2 ')).toBe('K7P2');
|
||||
expect(kids.normaliseCode('KIDS-K7P2')).toBe('K7P2');
|
||||
expect(kids.normaliseCode('k7-p2')).toBe('K7P2');
|
||||
});
|
||||
|
||||
test('anything that cannot be a code is null', () => {
|
||||
expect(kids.normaliseCode('')).toBeNull();
|
||||
expect(kids.normaliseCode('K7P')).toBeNull();
|
||||
expect(kids.normaliseCode('K7P22')).toBeNull();
|
||||
expect(kids.normaliseCode('K0P2')).toBeNull(); // no zero
|
||||
expect(kids.normaliseCode('KOP2')).toBeNull(); // no O
|
||||
expect(kids.normaliseCode('K1P2')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ages and rooms', () => {
|
||||
const on = new Date('2026-11-01T08:00:00Z');
|
||||
|
||||
test('age in whole years, birthday counted on the day', () => {
|
||||
expect(kids.ageOn('2021-11-01', on)).toBe(5);
|
||||
expect(kids.ageOn('2021-11-02', on)).toBe(4);
|
||||
expect(kids.ageOn(null, on)).toBeNull();
|
||||
});
|
||||
|
||||
const rooms = [
|
||||
{ id: 'all', name: 'Everyone', minAge: null, maxAge: null, order: 0 },
|
||||
{ id: 'tod', name: 'Toddlers', minAge: 1, maxAge: 3, order: 1, capacity: 2 },
|
||||
{ id: 'jun', name: 'Juniors', minAge: 4, maxAge: 7, order: 2 },
|
||||
];
|
||||
|
||||
test('fitsRoom respects the range; no age fits anywhere', () => {
|
||||
expect(kids.fitsRoom(rooms[1], 2)).toBe(true);
|
||||
expect(kids.fitsRoom(rooms[1], 4)).toBe(false);
|
||||
expect(kids.fitsRoom(rooms[1], null)).toBe(true);
|
||||
});
|
||||
|
||||
test('suggests the narrowest room that fits', () => {
|
||||
expect(kids.suggestRoom(rooms, 2).id).toBe('tod');
|
||||
expect(kids.suggestRoom(rooms, 5).id).toBe('jun');
|
||||
expect(kids.suggestRoom(rooms, 11).id).toBe('all');
|
||||
});
|
||||
|
||||
test('skips full rooms', () => {
|
||||
expect(kids.suggestRoom(rooms, 2, { tod: 2 }).id).toBe('all');
|
||||
});
|
||||
|
||||
test('without an age, the first room in order', () => {
|
||||
expect(kids.suggestRoom(rooms, null).id).toBe('all');
|
||||
});
|
||||
|
||||
test('nothing fits → null', () => {
|
||||
expect(kids.suggestRoom([rooms[1]], 9)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('room status', () => {
|
||||
test('ratio: leaders needed and over ratio', () => {
|
||||
expect(kids.roomStatus({ ratio: 5 }, 6, 1)).toMatchObject({ leadersNeeded: 2, overRatio: true });
|
||||
expect(kids.roomStatus({ ratio: 5 }, 5, 1)).toMatchObject({ leadersNeeded: 1, overRatio: false });
|
||||
expect(kids.roomStatus({ ratio: 5 }, 0, 0)).toMatchObject({ leadersNeeded: 0, overRatio: false });
|
||||
});
|
||||
|
||||
test('no ratio is never over ratio; capacity makes it full', () => {
|
||||
expect(kids.roomStatus({ capacity: 3 }, 3, 0)).toMatchObject({ full: true, overRatio: false });
|
||||
expect(kids.roomStatus({}, 30, 0)).toMatchObject({ full: false, overRatio: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sticker text', () => {
|
||||
test('allergy and medical flags, kept short', () => {
|
||||
expect(kids.stickerAlert('Peanuts', '')).toBe('ALLERGY: PEANUTS');
|
||||
expect(kids.stickerAlert('', 'Asthma pump in bag')).toBe('MEDICAL');
|
||||
expect(kids.stickerAlert('nuts', 'epipen')).toBe('ALLERGY: NUTS · MEDICAL');
|
||||
expect(kids.stickerAlert('', '')).toBe('');
|
||||
expect(kids.stickerAlert('a'.repeat(40), '')).toMatch(/^ALLERGY: A{24}…$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('occasions', () => {
|
||||
const single = { scheduleType: 'single', startDate: '2026-11-01T07:00:00Z', endDate: '2026-11-01T10:00:00Z' };
|
||||
|
||||
test('a single-date event: its day, only while it is on', () => {
|
||||
expect(kids.occasionFor(single, [], null, new Date('2026-11-01T06:00:00Z'))).toEqual({ occasion: '2026-11-01', sessionId: null });
|
||||
expect(() => kids.occasionFor(single, [], null, new Date('2026-11-02T08:00:00Z'))).toThrow(/open on 2026-11-01/);
|
||||
});
|
||||
|
||||
test('a multi-session event: today\'s session', () => {
|
||||
const ev = { scheduleType: 'sessions', checkInWindow: 'whole_day' };
|
||||
const sessions = [
|
||||
{ id: 's1', startsAt: '2026-11-01T07:00:00Z', endsAt: '2026-11-01T09:00:00Z' },
|
||||
{ id: 's2', startsAt: '2026-11-08T07:00:00Z', endsAt: '2026-11-08T09:00:00Z' },
|
||||
];
|
||||
expect(kids.occasionFor(ev, sessions, null, new Date('2026-11-08T06:30:00Z'))).toEqual({ occasion: 's2', sessionId: 's2' });
|
||||
expect(() => kids.occasionFor(ev, sessions, null, new Date('2026-11-04T08:00:00Z'))).toThrow(/next one/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('messages', () => {
|
||||
test('check-in message has the code and rooms, nothing medical', () => {
|
||||
const text = kids.checkInMessage({ eventTitle: 'Sunday Kids', code: 'K7P2', children: [{ name: 'Mia', room: 'Toddlers' }, { name: 'Ben', room: 'Juniors' }] });
|
||||
expect(text).toContain('Mia and Ben are checked in at Sunday Kids (Toddlers, Juniors)');
|
||||
expect(text).toContain('Pickup code: K7P2');
|
||||
expect(text).toContain('/dashboard/user/kids');
|
||||
});
|
||||
|
||||
test('for an authorised adult, no link to an account', () => {
|
||||
const text = kids.checkInMessage({ eventTitle: 'Sunday Kids', code: 'K7P2', children: [{ name: 'Mia' }], forParent: false });
|
||||
expect(text).toContain('Mia is checked in');
|
||||
expect(text).not.toContain('My account');
|
||||
});
|
||||
|
||||
test('listNames', () => {
|
||||
expect(kids.listNames(['A'])).toBe('A');
|
||||
expect(kids.listNames(['A', 'B', 'C'])).toBe('A, B and C');
|
||||
});
|
||||
});
|
||||
|
||||
describe('children', () => {
|
||||
test('cleanChild needs a first name and seals the notes', () => {
|
||||
expect(() => kids.cleanChild({})).toThrow(/first name/);
|
||||
const data = kids.cleanChild({ firstName: ' Mia ', allergies: 'Peanuts', custodyNotes: 'Not to Mr X' });
|
||||
expect(data.firstName).toBe('Mia');
|
||||
expect(data.allergies).toMatch(/^enc:/);
|
||||
expect(data.custodyNotes).toMatch(/^enc:/);
|
||||
expect(kids.childView({ id: 'c', ...data }).allergies).toBe('Peanuts');
|
||||
});
|
||||
|
||||
test('a future date of birth is refused', () => {
|
||||
expect(() => kids.cleanChild({ firstName: 'A', dateOfBirth: '2999-01-01' })).toThrow(/date of birth/);
|
||||
});
|
||||
|
||||
test('custodyChanged only when the text changes', () => {
|
||||
const existing = kids.cleanChild({ firstName: 'A', custodyNotes: 'x' });
|
||||
expect(kids.custodyChanged(existing, kids.cleanChild({ custodyNotes: 'x' }, existing))).toBe(false);
|
||||
expect(kids.custodyChanged(existing, kids.cleanChild({ custodyNotes: 'y' }, existing))).toBe(true);
|
||||
expect(kids.custodyChanged(existing, kids.cleanChild({ allergies: 'y' }, existing))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
jest.mock('../src/config/db', () => ({
|
||||
appSetting: { findUnique: jest.fn(), upsert: jest.fn(), findMany: jest.fn().mockResolvedValue([]) },
|
||||
$queryRawUnsafe: jest.fn(),
|
||||
}));
|
||||
|
||||
const prisma = require('../src/config/db');
|
||||
const retention = require('../src/utils/retention');
|
||||
|
||||
function saved(value) {
|
||||
prisma.appSetting.findUnique.mockResolvedValue(value === undefined ? null : { value: JSON.stringify(value) });
|
||||
}
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('policies', () => {
|
||||
test('defaults: inactive accounts after 5 years, on', async () => {
|
||||
saved(undefined);
|
||||
const { policies, reviewedAt } = await retention.getPolicies();
|
||||
expect(policies.inactive_accounts).toEqual({ enabled: true, months: 60 });
|
||||
expect(policies.form_answers).toEqual({ enabled: true, months: 12 });
|
||||
expect(reviewedAt).toBeNull();
|
||||
});
|
||||
|
||||
test('saved values override the defaults, one policy at a time', async () => {
|
||||
saved({ policies: { form_answers: { enabled: false, months: 6 } }, reviewedAt: '2026-10-23T00:00:00.000Z' });
|
||||
const { policies, reviewedAt } = await retention.getPolicies();
|
||||
expect(policies.form_answers).toEqual({ enabled: false, months: 6 });
|
||||
expect(policies.login_history).toEqual({ enabled: true, months: 24 });
|
||||
expect(reviewedAt).toBe('2026-10-23T00:00:00.000Z');
|
||||
});
|
||||
|
||||
test('saving refuses periods below the minimum or silly ones', async () => {
|
||||
const ok = Object.fromEntries(Object.entries(retention.POLICIES).map(([k, d]) => [k, { ...d.defaults }]));
|
||||
await expect(retention.savePolicies({ ...ok, inactive_accounts: { enabled: true, months: 6 } })).rejects.toThrow(/Inactive accounts: choose between 12 and 240/);
|
||||
await expect(retention.savePolicies({ ...ok, form_answers: { enabled: true, months: 'x' } })).rejects.toThrow(/Attendee form answers/);
|
||||
saved(undefined);
|
||||
await retention.savePolicies(ok, { reviewedBy: 'Ada' });
|
||||
const value = JSON.parse(prisma.appSetting.upsert.mock.calls[0][0].create.value);
|
||||
expect(value.reviewedBy).toBe('Ada');
|
||||
expect(value.policies.audit_logs).toEqual({ enabled: true, months: 60 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('describing periods', () => {
|
||||
test('whole years read as years', () => {
|
||||
expect(retention.describeMonths(60)).toBe('5 years');
|
||||
expect(retention.describeMonths(12)).toBe('1 year');
|
||||
expect(retention.describeMonths(18)).toBe('18 months');
|
||||
expect(retention.describeMonths(1)).toBe('1 month');
|
||||
});
|
||||
|
||||
test('monthsAgo goes back calendar months', () => {
|
||||
expect(retention.monthsAgo(24, new Date('2026-10-23T12:00:00Z')).toISOString()).toBe('2024-10-23T12:00:00.000Z');
|
||||
});
|
||||
|
||||
test('the notice lines follow the policies, and say so when one is off', async () => {
|
||||
saved({ policies: { inactive_accounts: { enabled: false, months: 60 }, form_answers: { enabled: true, months: 24 } } });
|
||||
const lines = await retention.noticeLines();
|
||||
expect(lines[0]).toBe('• Accounts are kept until you ask us to delete them.');
|
||||
expect(lines[1]).toBe('• Answers to event forms are deleted 2 years after the event.');
|
||||
expect(lines.every(l => l.startsWith('• '))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inactive accounts query', () => {
|
||||
test('not-yet-warned: a warning older than the last activity no longer counts', async () => {
|
||||
prisma.$queryRawUnsafe.mockResolvedValue([{ id: 'u1' }]);
|
||||
const before = new Date('2021-10-23');
|
||||
expect(await retention.inactiveAccounts(before, { warned: false, limit: 10 })).toEqual(['u1']);
|
||||
const [sql, ...params] = prisma.$queryRawUnsafe.mock.calls[0];
|
||||
expect(sql).toMatch(/warned_at IS NULL OR warned_at < last_active/);
|
||||
expect(sql).toMatch(/u\."role" = 'user'/);
|
||||
expect(params).toEqual([before]);
|
||||
});
|
||||
|
||||
test('due for deletion: warned since the last activity, long enough ago', async () => {
|
||||
prisma.$queryRawUnsafe.mockResolvedValue([]);
|
||||
const before = new Date('2021-10-23');
|
||||
const warnedBefore = new Date('2026-09-23');
|
||||
await retention.inactiveAccounts(before, { warnedBefore });
|
||||
const [sql, ...params] = prisma.$queryRawUnsafe.mock.calls[0];
|
||||
expect(sql).toMatch(/warned_at >= last_active AND warned_at <= \$2/);
|
||||
expect(params).toEqual([before, warnedBefore]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
const v = require('../src/utils/volunteers');
|
||||
const { isBlocked } = require('../src/hub/readOnly');
|
||||
|
||||
const event = {
|
||||
id: 'e1', startDate: new Date('2026-11-08T06:00:00Z'), endDate: new Date('2026-11-08T10:00:00Z'),
|
||||
sessions: [{ id: 's1', startsAt: new Date('2026-11-15T16:30:00Z'), endsAt: new Date('2026-11-15T18:30:00Z') }],
|
||||
};
|
||||
|
||||
describe('shifts', () => {
|
||||
test("a role's shift: its own times, else its session's, else the event's", () => {
|
||||
expect(v.shiftOf({}, event)).toEqual({ startsAt: event.startDate, endsAt: event.endDate });
|
||||
expect(v.shiftOf({ sessionId: 's1' }, event)).toEqual({ startsAt: event.sessions[0].startsAt, endsAt: event.sessions[0].endsAt });
|
||||
const own = { startsAt: '2026-11-08T05:30:00Z', endsAt: '2026-11-08T07:00:00Z' };
|
||||
expect(v.shiftOf(own, event)).toEqual({ startsAt: new Date(own.startsAt), endsAt: new Date(own.endsAt) });
|
||||
});
|
||||
|
||||
test('overlapping shifts; touching ends are fine', () => {
|
||||
const a = { startsAt: '2026-11-08T06:00:00Z', endsAt: '2026-11-08T08:00:00Z' };
|
||||
expect(v.overlaps(a, { startsAt: '2026-11-08T07:00:00Z', endsAt: '2026-11-08T09:00:00Z' })).toBe(true);
|
||||
expect(v.overlaps(a, { startsAt: '2026-11-08T08:00:00Z', endsAt: '2026-11-08T09:00:00Z' })).toBe(false);
|
||||
expect(v.overlaps(a, { startsAt: '2026-11-08T05:00:00Z', endsAt: '2026-11-08T06:00:00Z' })).toBe(false);
|
||||
});
|
||||
|
||||
test('hours to the nearest quarter hour', () => {
|
||||
expect(v.shiftHours({ startsAt: '2026-11-08T06:00:00Z', endsAt: '2026-11-08T08:30:00Z' })).toBe(2.5);
|
||||
expect(v.shiftHours({ startsAt: '2026-11-08T06:00:00Z', endsAt: '2026-11-08T06:50:00Z' })).toBe(0.75);
|
||||
expect(v.shiftHours({ startsAt: '2026-11-08T06:00:00Z', endsAt: '2026-11-08T06:00:00Z' })).toBe(0);
|
||||
});
|
||||
|
||||
test('fill', () => {
|
||||
expect(v.fillOf(4, 3)).toEqual({ needed: 4, filled: 3, open: 1, full: false });
|
||||
expect(v.fillOf(2, 3)).toEqual({ needed: 2, filled: 3, open: 0, full: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('check-in window', () => {
|
||||
const shift = { startsAt: new Date('2026-11-08T06:00:00Z'), endsAt: new Date('2026-11-08T08:00:00Z') };
|
||||
test('open on the day of the shift until it ends', () => {
|
||||
expect(v.checkInOpen(shift, new Date('2026-11-08T04:00:00Z'))).toBe(true); // early, same day (SAST)
|
||||
expect(v.checkInOpen(shift, new Date('2026-11-08T07:59:00Z'))).toBe(true);
|
||||
});
|
||||
test('closed the day before and after the shift ends', () => {
|
||||
expect(v.checkInOpen(shift, new Date('2026-11-07T12:00:00Z'))).toBe(false);
|
||||
expect(v.checkInOpen(shift, new Date('2026-11-08T08:01:00Z'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reminders', () => {
|
||||
const shift = { startsAt: new Date('2026-11-08T06:00:00Z'), endsAt: new Date('2026-11-08T08:00:00Z') };
|
||||
const signedUp = new Date('2026-11-01T10:00:00Z');
|
||||
test('due within 24 hours of the start, once', () => {
|
||||
expect(v.reminderDue({ remindedAt: null, createdAt: signedUp }, shift, new Date('2026-11-07T07:00:00Z'))).toBe(true);
|
||||
expect(v.reminderDue({ remindedAt: new Date(), createdAt: signedUp }, shift, new Date('2026-11-07T07:00:00Z'))).toBe(false);
|
||||
});
|
||||
test('not too early, not after the start', () => {
|
||||
expect(v.reminderDue({ remindedAt: null, createdAt: signedUp }, shift, new Date('2026-11-07T05:00:00Z'))).toBe(false);
|
||||
expect(v.reminderDue({ remindedAt: null, createdAt: signedUp }, shift, new Date('2026-11-08T06:00:00Z'))).toBe(false);
|
||||
});
|
||||
test('not for someone who signed up minutes ago', () => {
|
||||
const now = new Date('2026-11-07T20:00:00Z');
|
||||
expect(v.reminderDue({ remindedAt: null, createdAt: new Date(now.getTime() - 10 * 60 * 1000) }, shift, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kids rooms', () => {
|
||||
test('a session role counts for its session; otherwise for the day', () => {
|
||||
expect(v.kidsOccasion({ sessionId: 's1' })).toBe('s1');
|
||||
expect(v.kidsOccasion({ sessionId: null }, new Date('2026-11-08T06:00:00Z'))).toBe('2026-11-08');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reserved tickets', () => {
|
||||
test('the kids and volunteer tickets never show publicly', () => {
|
||||
const ev = { kidsOptionId: 'k', volunteerOptionId: 'vol', eventOptions: [{ id: 'adult' }, { id: 'k' }, { id: 'vol' }] };
|
||||
expect(v.reservedOptionIds(ev)).toEqual(['k', 'vol']);
|
||||
expect(v.publicOptions(ev).map(o => o.id)).toEqual(['adult']);
|
||||
expect(v.publicOptions({ eventOptions: [{ id: 'a' }] }).map(o => o.id)).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('read-only licence', () => {
|
||||
test.each([
|
||||
['POST', '/api/volunteers/roles/r1/sign-up'],
|
||||
['POST', '/api/volunteers/manage/events/e1/roles'],
|
||||
['POST', '/api/volunteers/manage/roles/r1/assign'],
|
||||
])('%s %s is blocked', (m, p) => expect(isBlocked(m, p)).toBe(true));
|
||||
test.each([
|
||||
['POST', '/api/volunteers/check-in'],
|
||||
['POST', '/api/volunteers/swaps/w1/accept'],
|
||||
['DELETE', '/api/volunteers/slots/s1'],
|
||||
])('%s %s keeps working', (m, p) => expect(isBlocked(m, p)).toBe(false));
|
||||
});
|
||||
@@ -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
|
||||
|
||||
+149
-12
@@ -1,13 +1,32 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-23, after **1.39.0**. The plan itself is in
|
||||
Last updated: 2026-10-28, after **1.46.1** (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.46.0 | **Volunteer rosters** (§5): serving roles per event or session, anyone signs up (or supervisors assign), day-before reminders, "can't make it" swaps that happen on accept (notify recipients emailed), self check-in with the event's QR code or by staff, kids-room roles count as leaders on duty, free volunteer ticket off by default, roster sheet and hours served. Also fixes the 1.45.0 kids ticket being offered publicly |
|
||||
| 1.46.1 | Removed the Gitea CI workflow (no runner): test, lint and deploy by hand |
|
||||
| 1.45.1 | Privacy notice (built-in text) and terms of use brought up to date with kids check-in, sessions, certificates, campuses, the Hub heartbeat and where data is stored. Installs with a published notice must publish a new version themselves |
|
||||
| 1.45.0 | **Kids check-in** (§4), fully digital: families/children/authorised adults kept between events (My account → Kids), staff desk with pickup codes and a "write on the sticker" panel, rooms with age ranges and ratios, room screen (on duty, call parent, alerts), check-out by code (wrong code alerts, override with reason), offline desk with reserved codes, attendance through a free kids ticket; 6-month retention rule for children |
|
||||
| 1.44.0 | **Campus-scoped staff** (§12 part 2): Admin → Users "Limit to campuses"; `utils/campusScope.js` on every staff/supervisor route (and owner-or-staff routes), lists filtered, scanner names the other campus, church-wide events view/scan/door-only for scoped staff; route test `tests/campusScopeRoutes.test.js` |
|
||||
| 1.43.0 | **Campuses** (§12 part 1): Site settings → Campuses (off by default), events per campus or all campuses, public campus filter (remembered, starts on home campus) and `/campus/<slug>` pages, home campus on the profile, campus filters on Reports and the dashboards, "Everyone at a campus" in broadcasts, campus summary recipients, campus on ticket PDFs, heartbeat `campusCount`. Also adds the `hub_update_requested` audit action missing since 1.42.0 |
|
||||
| 1.42.1 | Production Hub public key built in (`publicKey.js`); `HUB_PUBLIC_KEY` only needed for a local Hub |
|
||||
| 1.42.0 | **Request update** instead of the deploy command: Site settings → Licence sends a signed update request to the Hub (protocol §3a), which opens an alert and emails Joshua; "Update requested on …" until the site runs that version, and the admin update banner hides meanwhile. Audit action `hub_update_requested`. Needs Hub 1.1.0 |
|
||||
| 1.41.3 | Renamed Hope Events → **Cross Events** in all UI text and docs (technical identifiers unchanged, see above) |
|
||||
| 1.41.2 | Background jobs page: last-run times were 2 h ahead (Prisma's pg adapter drops the offset of timestamptz in raw queries — read pg-boss times `AT TIME ZONE 'UTC'`); `APP_TIMEZONE` documented |
|
||||
| 1.41.1 | Licence fixes — no licence (never connected, or the Hub answers `INSTALL_NOT_FOUND`, which now drops the old licence) is read-only like suspended; read-only also blocks editing events/options/variants; heartbeat 30 s after start and after connecting; `LICENCE_CHECK_DISABLED` for e2e |
|
||||
| 1.41.0 | Cross Events Hub connection — licence step in `/setup` and Site settings → Licence, 15-minute heartbeat (health, totals, income bands), licence states checked offline (grace banner for site admins only, read-only when suspended/revoked), `/health`, "update available", `scripts/install.sh`. The Hub itself is `joshua/events-hub` 1.0.0 |
|
||||
| 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 |
|
||||
| 1.32.0 | Early-bird ribbon on event cards |
|
||||
@@ -24,13 +43,10 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
## Next up
|
||||
|
||||
1. **1.40.0 — POPIA toolkit part 3** (spec §3 in the roadmap, decisions table): retention
|
||||
policies per data category + nightly job (inactive accounts anonymised after 5 years, **on by
|
||||
default**, warning email 30 days before, never staff/supervisor/admin), breach register with
|
||||
notification templates, children's-data flags in reports, POPIA readiness page with a score.
|
||||
The notice's retention section should then fill in from the policies (a new placeholder).
|
||||
2. **1.41.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo
|
||||
`joshua/hope-events-hub`).
|
||||
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
|
||||
a test server (both done 2026-10-28).
|
||||
|
||||
1. **1.47.0 — Event series & cloning** (§6), then **1.48.0 — outbound webhooks** (§8).
|
||||
|
||||
## Working on this repo
|
||||
|
||||
@@ -83,9 +99,9 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
- **Careful with ad-hoc scripts against a dev/test database that has SMTP set up:**
|
||||
registrations and check-ins send real email/WhatsApp. Use made-up `@test.local`
|
||||
people only.
|
||||
- CI: `.gitea/workflows/ci.yml` runs everything once a Gitea Actions runner is
|
||||
registered (none yet). Tag deploys need the `DEPLOY_HOST`, `DEPLOY_USER`,
|
||||
`DEPLOY_SSH_KEY` (+ optional `DEPLOY_PATH`) secrets.
|
||||
- No CI: the Gitea workflow was removed (no machine to run a Docker runner). Run the
|
||||
backend tests, frontend eslint and e2e tests yourself before tagging, and deploy by
|
||||
hand with `scripts/deploy.sh <tag>` on the server.
|
||||
|
||||
## Where the multi-day code lives
|
||||
|
||||
@@ -144,9 +160,123 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
- Access log: `utils/accessLog.js` (`logDataAccess(req, { action, what, eventId, records })`).
|
||||
**Any new bulk export or list of personal data should call it** and get a label in
|
||||
`ACCESS_ACTIONS`. Operators: `Operator` table, seeded in the 1.39.0 migration.
|
||||
- e2e: `privacy-notice.spec.ts` publishes a version that needs accepting, then accepts it for all
|
||||
- Retention: `utils/retention.js` (policies in AppSetting `retention_policies`, `runRetention`,
|
||||
`preview`, `noticeLines` for `{retention}`), job `retention` (03:30). **Anything new that
|
||||
stores personal data should get a retention rule** (a new entry in `POLICIES` + runner).
|
||||
Inactive = latest of `User.createdAt`, `lastLoginAt` (set on login), registrations/event end,
|
||||
attendee rows, payments. Readiness: `utils/readiness.js`. Breaches: `SecurityBreach`.
|
||||
Admin panels: `components/admin/privacy/{Readiness,Retention,Breaches}Panel.tsx`.
|
||||
- e2e: `privacy-retention.spec.ts` runs the retention job on seeded six-year-old accounts
|
||||
(`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, 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, built in since 1.42.1 — if the Hub's
|
||||
signing key is ever regenerated, update it here in a release; `HUB_PUBLIC_KEY` env overrides it
|
||||
for a local Hub), `licence.js` (verify tokens, work out
|
||||
active / grace / suspended / revoked / unlicensed, cached 30 s), `client.js` (connect, heartbeat,
|
||||
update request — the last two share `signedPost`; AppSetting `hub_*` keys, which the generic
|
||||
settings endpoints never touch), `report.js` (health,
|
||||
totals, income bands), `readOnly.js` (the routes blocked in read-only mode — add any new
|
||||
"create" or event-edit route there). Read-only = suspended, revoked or unlicensed.
|
||||
- Job `hub-heartbeat` (every 15 min), routes `/api/hub/*` (status, connect, heartbeat,
|
||||
update-request), `/health`.
|
||||
- Frontend: `components/admin/LicenceTab.tsx` (Site settings → Licence, with the **Request update**
|
||||
button; its `ConnectForm` is also on the last `/setup` screen), `components/shared/LicenceBanner.tsx`
|
||||
(site admins only).
|
||||
- **Updates:** sites never update themselves and never show the deploy command. "Request update"
|
||||
tells the Hub (an `update_requested` alert + one email to Joshua); the Hub resolves it when the
|
||||
site's heartbeat reports a newer version. Joshua then runs `scripts/deploy.sh` on the server.
|
||||
- Local testing: run the Hub (`../events-hub`, ports 5200/3200), create a licence there, and start
|
||||
Cross Events with `HUB_URL=http://localhost:5200` and `HUB_PUBLIC_KEY` = the key on Hub → Settings.
|
||||
- `scripts/install.sh` — fresh Ubuntu server to a running site (written and syntax-checked; not yet
|
||||
run on a real server).
|
||||
|
||||
## Where campuses live (1.43.0)
|
||||
|
||||
- `backend/src/utils/campuses.js`: the setting (`campuses_enabled`, off by default), `campusWhere`
|
||||
(list filters: one campus, `'all'` = church-wide only, `withAllCampuses` for the public list),
|
||||
`campusForEvent` (what to save on an event), `campusAudienceWhere` (broadcast audience). Pure
|
||||
parts are tested in `tests/campuses.test.js`.
|
||||
- `controllers/campusController.js`, routes `/api/campuses/*`. The setting is changed only through
|
||||
`PUT /api/campuses/settings` (it's in `PRIVATE_KEYS` in the settings controller).
|
||||
- **While campuses are off, every campus field and filter is ignored** (the data stays). New code
|
||||
should check `campusesEnabled()` the same way.
|
||||
- Frontend: `hooks/useCampuses.ts` (one cached `GET /api/campuses` per page load),
|
||||
`components/shared/CampusFilter.tsx` (`CampusFilter`, `matchesCampus`, `CampusBadge`),
|
||||
`CampusAudiencePicker.tsx`, `components/admin/CampusesTab.tsx`, `components/events/EventsGrid.tsx`
|
||||
(public filter, `hope_events_campus` in localStorage), `app/campus/[slug]/page.tsx`.
|
||||
- e2e: the campuses and two events' campuses are **seeded** in `e2e/support/prepare-db.js` with
|
||||
campuses off (so a pre-built frontend already has them); `campuses.spec.ts` turns them on and off.
|
||||
|
||||
## Campus-scoped staff (1.44.0) — adding a staff route
|
||||
|
||||
- **Every route behind `staff` or `supervisor` must carry a marker from `utils/campusScope.js`**, or
|
||||
`tests/campusScopeRoutes.test.js` fails:
|
||||
- `scopeEvent({ resolve, from, need, ownerOk, nameCampus })` — one event, or a record traced to
|
||||
one (`RESOLVERS`: registration, ticket, ticketQr, payment, eventOption, optionVariant, bundle,
|
||||
allocationSet/Group, waitlistEntry, scanLock, syncReport, section, certificate, consentRequest).
|
||||
`need: 'manage'` for setup, cashup and refunds (church-wide events refused).
|
||||
- `scopeBody([[field, resolver], …], { need, orphan })` — records named in the body (lists of ids
|
||||
too); `orphan: 'deny'` when there must be an event (payments).
|
||||
- `scopeList` — the controller filters with `listWhere(req, path)` / `withScope(where, extra)`
|
||||
(never spread a scope `where` over one that has its own OR) or checks itself.
|
||||
- `unscopedOnly`, `campusIndependent('reason')`, `scopeBroadcast({ freeformField })`.
|
||||
- Routes open to the owner *and* staff (no role middleware, so the test can't see them) need
|
||||
`scopeEvent({ …, ownerOk: true })` by hand — registrations, payments, tickets, consent requests
|
||||
and coupon validation have it.
|
||||
- Rules: admins never scoped; scopes ignored while campuses are off; another campus → 404, except
|
||||
the scanner (403 naming the campus). Tested in `tests/campusScope.test.js` and e2e
|
||||
`campus-scoped.spec.ts` (staff and supervisor scoped to E2E South; a seeded North ticket).
|
||||
|
||||
## Where kids check-in lives (1.45.0)
|
||||
|
||||
- `backend/src/utils/kids.js` — everything: codes (`randomCode`, `normaliseCode`), occasions
|
||||
(`occasionFor`: today's session, or the day), room suggestion and status, families and children
|
||||
(allergies/medical/custody encrypted with `utils/encryption.js`), `checkIn` (code per family per
|
||||
occasion via `KidsPickupCode`, a free place on `Event.kidsOptionId` through `ticketForChild`, a
|
||||
`TicketUsage` per check-in), `checkOut` / `overrideCheckOut`, messages, `board`, duty, offline
|
||||
snapshot and `syncOffline`, and the privacy parts (`removeFromFamily`, `exportFamily`,
|
||||
`runKidsRetention`). Pure parts tested in `tests/kids.test.js`.
|
||||
- `controllers/kidsController.js` + `routes/kidsRoutes.js` (`/api/kids`); campus resolvers
|
||||
`kidsCheckIn` and `kidsAlert` in `utils/campusScope.js`.
|
||||
- Rooms are `AllocationGroup`s of `Event.kidsRoomSetId` (with `minAge`, `maxAge`, `ratio`).
|
||||
Event ids on `Event` (`kidsOptionId`, `kidsRoomSetId`) are plain strings, checked in code.
|
||||
- Kids check-in isn't blocked in licence read-only mode, on purpose: it's like scanning tickets,
|
||||
and children's safety at pickup comes first.
|
||||
- Frontend: `lib/kids.ts` (types, room suggestion, offline copy in `hope_events_kids_offline_<eventId>`),
|
||||
`components/kids/*` (`KidsEventPicker`, `ChildForm`, `RoomBoard`, `StickerPanel`), pages
|
||||
`app/dashboard/staff/kids` (desk), `staff/kids/rooms`, `supervisor/kids` (setup), `user/kids`.
|
||||
- e2e: `kids.spec.ts` on the seeded hidden "E2E Kids Church" (set up, parent adds a child, desk
|
||||
checks in, parent sees the code, wrong code then the right one, room alert).
|
||||
|
||||
## Where volunteer rosters live (1.46.0)
|
||||
|
||||
- `backend/src/utils/volunteers.js` — everything: shifts (`shiftOf`: the role's own times, else its
|
||||
session's, else the event's), overlap and check-in-window rules, roles, sign-up / leave / swaps,
|
||||
the free volunteer place (`givePlace` / `releasePlace`, `VolunteerSlot.registrationId`), QR and
|
||||
manual check-in (kids-room roles start a `KidsRoomDuty` with `fromRoster`), `rosteredForRooms`
|
||||
(used by the kids board), messages, the `volunteers` reminder job, hours served, and the privacy
|
||||
parts (`exportVolunteering`, `forgetVolunteer`). Pure parts tested in `tests/volunteers.test.js`.
|
||||
- `reservedOptionIds` / `publicOptions` there: the kids and volunteer tickets are left out of public
|
||||
event pages and refused on public registration (`eventController`, `registrationController`).
|
||||
**A new staff-only ticket type should be added to `reservedOptionIds`.**
|
||||
- `controllers/volunteerController.js` + `routes/volunteerRoutes.js` (`/api/volunteers`); campus
|
||||
resolvers `servingRole` and `volunteerSlot`; sign-ups, new roles and assigning are in `readOnly.js`.
|
||||
- Swaps: no approval — accepting moves the slot at once (claimed with an `updateMany` on
|
||||
`status: 'open'` so two people can't both take it); `tellOrganisers` emails
|
||||
`getEventNotifyEmails(event)` (notify recipients, else the creator), not the registrations inbox.
|
||||
- The check-in QR holds `SERVE-<Event.volunteerCheckInToken>`; the page encodes it as a URL
|
||||
(`/serve/check-in?code=…`) so phone cameras open it directly.
|
||||
- Frontend: `lib/volunteers.ts`, `components/volunteers/{RosterSheet,CheckInQr}.tsx`, pages
|
||||
`dashboard/supervisor/volunteers` (roles, QR, ticket, hours), `dashboard/staff/volunteers` (the day),
|
||||
`dashboard/user/serving` (`?event=<id>` opens one event — the shareable sign-up link), `serve/check-in`.
|
||||
- e2e: `volunteers.spec.ts` on the seeded hidden "E2E Serving Sunday" (role, ticket hidden publicly,
|
||||
sign-up, cover request, staff take over, QR check-in, roster).
|
||||
|
||||
## Known gaps / follow-ups
|
||||
|
||||
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
|
||||
@@ -165,3 +295,10 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
changes to picked lines). "Add & charge" for a session at the scanner isn't built.
|
||||
- Ticket scanning by camera isn't covered by the e2e tests (headless browsers have no
|
||||
camera); scans are tested through the API.
|
||||
- Kids check-in: the offline desk is tested at the API level (sync, idempotency, code
|
||||
reservation), not in the browser. A family's two parents share one family; linking a second
|
||||
parent is done at the desk ("parents" endpoint) — there's no invite from My account yet.
|
||||
- Volunteer rosters: if a volunteer edits their free volunteer registration on the edit page, the
|
||||
volunteer ticket isn't among the public options, so saving drops it (the backend only refuses
|
||||
adding or increasing it). Rare; the place is still in the roster, and a supervisor can give a
|
||||
ticket at the door. Reminders are a single "day before" one (no per-event timing yet).
|
||||
|
||||
+303
-75
@@ -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
|
||||
@@ -8,9 +8,10 @@ previous round (1.12.0 – 1.23.0). Where to pick up: [HANDOVER.md](./HANDOVER.m
|
||||
|
||||
**Theme for 2.0:** turn a feature-rich church app into a product that can be
|
||||
sold — trustworthy (POPIA), flexible (multi-day events, several payment
|
||||
providers), and maintainable (tests, CI, typed backend, versioned API).
|
||||
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
|
||||
@@ -20,15 +21,27 @@ picker at the door and session choice on "All sessions" tickets; 1.36.0 payment
|
||||
(Yoco moved behind it, Site settings → Payments); 1.37.0 PayFast and the "Pay online" page;
|
||||
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).
|
||||
**Next: 1.40.0 POPIA toolkit part 3 (retention, breach register, readiness).** Hub moved to 1.41.0.
|
||||
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`);
|
||||
1.41.1–1.41.2 licence and job-time fixes; 1.41.3 renamed **Hope Events → Cross Events** (Hub 1.0.1:
|
||||
Cross Events Hub); 1.42.0 **Request update** button instead of the deploy command, with Hub 1.1.0
|
||||
(update requests); 1.42.1 the production Hub key built in. Later versions moved up by one
|
||||
(2026-10-27). Multi-campus is split in two — 1.43.0 campuses, 1.44.0 campus-scoped staff — so
|
||||
everything after moves up by one more (2026-10-27). 1.43.0 **campuses** shipped (settings, events
|
||||
per campus, public filter and campus pages, home campus, filters, campus broadcasts and summaries);
|
||||
1.44.0 **campus-scoped staff** shipped (Admin → Users "Limit to campuses", every staff route checked).
|
||||
Wallet passes are removed from scope (2026-10-28), so everything after volunteer rosters moves
|
||||
up by one. 1.45.0 **kids check-in** shipped (fully digital: pickup codes, sticker panel, rooms with
|
||||
ratios, offline desk). 1.46.0 **volunteer rosters** shipped (roles per event or session, sign-ups and
|
||||
assigning, swaps on accept, QR self check-in, kids-room roles on duty, optional volunteer ticket).
|
||||
**Next: 1.47.0 Event series and cloning.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
| Repo | What |
|
||||
|------|------|
|
||||
| `joshua/hope-events` (this repo) | The church app. Everything in this document except the Hub app itself — including the Hub *client* (licence step, "Connect to Hub", heartbeat, licence checks) |
|
||||
| `joshua/hope-events-hub` (new) | The Hub app (§13): its own versions, CHANGELOG, releases and server |
|
||||
| `joshua/events-hub` (new) | The Hub app (§13): its own versions, CHANGELOG, releases and server |
|
||||
|
||||
---
|
||||
|
||||
@@ -37,19 +50,21 @@ notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (
|
||||
1. [Multi-day events (sessions)](#1-multi-day-events-sessions) ← **first**
|
||||
2. [Payment providers: PayFast](#2-payment-providers-payfast)
|
||||
3. [POPIA toolkit](#3-popia-toolkit)
|
||||
4. [Kids check-in with security labels](#4-kids-check-in-with-security-labels)
|
||||
4. [Kids check-in (digital pickup codes)](#4-kids-check-in-digital-pickup-codes)
|
||||
5. [Volunteer / serving rosters](#5-volunteer--serving-rosters)
|
||||
6. [Event series and cloning](#6-event-series-and-cloning)
|
||||
7. [Apple and Google Wallet tickets](#7-apple-and-google-wallet-tickets)
|
||||
7. ~~[Apple and Google Wallet tickets](#7-apple-and-google-wallet-tickets)~~ — removed from scope
|
||||
8. [Outbound webhooks](#8-outbound-webhooks)
|
||||
9. [Admin dashboard, command palette, bulk actions](#9-admin-dashboard-command-palette-bulk-actions)
|
||||
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. [Suggested build order](#suggested-build-order)
|
||||
16. [Decisions](#decisions-2026-10-22)
|
||||
15. [AI helpers (2.0 headline)](#15-ai-helpers-20-headline)
|
||||
16. [AI: later stages](#16-ai-later-stages)
|
||||
17. [Suggested build order](#suggested-build-order)
|
||||
18. [Decisions](#decisions-2026-10-22)
|
||||
|
||||
---
|
||||
|
||||
@@ -382,43 +397,55 @@ self-service privacy tools.
|
||||
|
||||
---
|
||||
|
||||
## 4. Kids check-in with security labels
|
||||
## 4. Kids check-in (digital pickup codes)
|
||||
|
||||
A secure check-in/check-out flow for children's ministry and youth events.
|
||||
**Builds on sessions (§1), attendees, parental consent and allocation groups.**
|
||||
A secure check-in/check-out flow for children's ministry, run from a **staff desk**.
|
||||
**Builds on sessions (§1), attendees, allocation groups and campuses (§12).** Fully digital —
|
||||
no label printers (2026-10-28).
|
||||
|
||||
**Who:** Parents check in children (self-service kiosk or staff desk).
|
||||
Kids-ministry staff run rooms. Supervisors set up rooms.
|
||||
**Who:** Kids-ministry staff at the check-in desk and in the rooms. Supervisors set up rooms.
|
||||
Parents manage their family's children in My account and see pickup codes on their phone.
|
||||
|
||||
### How it works
|
||||
|
||||
- Parent finds their family (phone number / QR from My account) and ticks the
|
||||
children being dropped off. New children can be added on the spot.
|
||||
- The system prints **two labels**:
|
||||
- **Child label:** name, room, allergy/medical icons, short pickup code.
|
||||
- **Parent label:** the same pickup code.
|
||||
- **Check-out** requires scanning or typing the matching code. A mismatch
|
||||
alerts the room leader.
|
||||
- Rooms come from allocation groups (age/grade rules), with **ratios** (e.g.
|
||||
1 leader per 5 toddlers) and live room counts.
|
||||
- **Page a parent:** room leader taps "Call parent" → WhatsApp/SMS to the
|
||||
parent ("Please come to the Toddlers room").
|
||||
- Attendance per child per session feeds the attendance register.
|
||||
- **Families and children are kept between events** (weekly kids church): a family has one or
|
||||
more parent accounts, its children (name, date of birth, allergies, medical notes, custody
|
||||
notes — the last three encrypted) and **authorised adults** (name, phone, relationship — no
|
||||
account needed). Parents manage them under My account → Kids; staff can find a family by
|
||||
phone, name or the parent's QR code and add a family or child on the spot.
|
||||
- An event gets a **Kids check-in** switch (single events or one session per Sunday). The first
|
||||
check-in makes a free registration under the parent with an attendee and ticket per child, and
|
||||
each check-in writes a `TicketUsage`, so the attendance register, reports and campus scoping work
|
||||
as they do for tickets.
|
||||
- Each check-in gets a **pickup code** (4 characters, no 0/O/1/I, unique per event and day). The
|
||||
parent sees it live on their phone (code + QR) and gets it by WhatsApp/email; it can also be
|
||||
sent to an authorised adult. **Anyone with the code can collect** the child.
|
||||
- **"Write on the sticker" panel** on the desk screen: first name, room, code and a short alert
|
||||
(ALLERGY / MEDICAL) in large print, for volunteers to copy onto a blank sticker. The full
|
||||
details stay on screen.
|
||||
- **Rooms** are a "Kids rooms" allocation set: each room has an age range, capacity and a
|
||||
**ratio** (children per leader). The room is suggested from the child's age. Room screens show
|
||||
live cards (name, code, allergy/medical icons, custody notes) and counts: children in, leaders
|
||||
on duty ("I'm on duty"), over-ratio and full warnings.
|
||||
- **Check-out** by scanning the parent's QR or typing the code; the leader records who collected.
|
||||
A wrong code alerts the room and is logged; "Override" needs a reason.
|
||||
- **Page a parent:** "Call parent" → WhatsApp (email fallback) and a notice on the parent's page.
|
||||
|
||||
### Plumbing / edge cases
|
||||
|
||||
- Label printing via browser print with label-size CSS (Dymo / Brother label
|
||||
printers, 62 mm and 89×36 mm presets); PDF fallback.
|
||||
- Allergies and medical info come from the consent/medical fields; respect
|
||||
the medical-info deletion rules.
|
||||
- Custody notes ("Do not release to …") visible to room leaders only.
|
||||
- Works on the existing offline-scanning foundations for flaky Wi-Fi.
|
||||
- Medical and custody notes: room leaders, supervisors and admins only; never in messages;
|
||||
room lists showing them are written to the access log.
|
||||
- POPIA: families and children in `anonymiseUser` / `exportUserData`; retention rule — a child's
|
||||
profile is deleted after **6 months** without a check-in; readiness flags children's data.
|
||||
- Offline: the desk caches the event's families and checks in offline (device-prefixed codes,
|
||||
synced later). Check-out offline only for children checked in on the same device.
|
||||
- Audit log: overrides, wrong codes and custody-note changes.
|
||||
|
||||
---
|
||||
|
||||
## 5. Volunteer / serving rosters
|
||||
|
||||
Plan who serves where, for an event or each session.
|
||||
Plan who serves where, for an event or each session. (Shipped in 1.46.0 — decisions below.)
|
||||
|
||||
- **Serving roles** per event/session: Welcome, Parking, Kids, Sound, Kitchen…
|
||||
with the number of people needed.
|
||||
@@ -447,6 +474,8 @@ Plan who serves where, for an event or each session.
|
||||
|
||||
## 7. Apple and Google Wallet tickets
|
||||
|
||||
> **Removed from scope (2026-10-28).** Kept here for reference only; not planned for 2.0.
|
||||
|
||||
- "Add to Apple Wallet" / "Add to Google Wallet" buttons next to the PDF
|
||||
ticket, in email and in My Events.
|
||||
- Pass shows event, date (next session for multi-day), location, holder name,
|
||||
@@ -460,7 +489,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`,
|
||||
@@ -513,7 +542,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
|
||||
|
||||
@@ -527,17 +561,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).
|
||||
@@ -549,21 +588,64 @@ install.
|
||||
- Moving an event to another campus → scoped supervisors of the old campus
|
||||
lose access; logged in the audit log.
|
||||
|
||||
### Plumbing (1.44.0)
|
||||
|
||||
- Today access depends only on role — nothing limits staff to particular
|
||||
events — so scoping is a new layer: one helper (`utils/campusScope.js`) that
|
||||
works out a user's campuses once per request, gives a `where` for event lists
|
||||
and checks single records (registrations, tickets, payments… traced back to
|
||||
their event).
|
||||
- A test lists **every** staff/supervisor route and fails unless each is
|
||||
campus-checked or explicitly marked campus-independent (the same pattern as
|
||||
the read-only route test), so new endpoints can't skip the check.
|
||||
- The heartbeat's `features.campuses` / `stats.campuses` report the real setting
|
||||
(hard-coded `false` until 1.43.0).
|
||||
- **How each kind of endpoint is checked:** event id in the URL → a route middleware
|
||||
(`scopeEvent`); a record that belongs to an event (registration, ticket/QR, waitlist
|
||||
entry, option, bundle, allocation set…) → traced back to its event, same check;
|
||||
lists across events → the controller adds the scope `where` (route marked so the
|
||||
test knows); an event named in the body → checked before saving; nothing to do with
|
||||
an event → allowed or unscoped-only, listed with a reason.
|
||||
- **Rules (decided 2026-10-27):**
|
||||
- Admins are never scoped. Scoped supervisors and staff see their campuses' events
|
||||
plus "All campuses" events.
|
||||
- "All campuses" events: scoped staff can view, scan, take payments at the door and
|
||||
do manual registrations; event setup, options, bundles, cashup and refunds stay with
|
||||
admins and unscoped supervisors.
|
||||
- Site banner, payment-provider reconciliation and donations not linked to an event:
|
||||
admins and unscoped supervisors only. Ad-hoc broadcasts: scoped supervisors only to
|
||||
their campuses' audiences and their events' attendees. Coupons: only for their
|
||||
campuses' events.
|
||||
- Another campus's records answer "not found" (nothing leaks); the scanner instead
|
||||
says which campus the ticket is for.
|
||||
- While campuses are off, scopes are ignored (everyone sees everything); the Campuses
|
||||
tab warns which staff are limited before turning campuses off.
|
||||
- Scopes are set by admins in Admin → Users ("Limit to campuses"); changes are audited
|
||||
(`user_campus_scope_changed`).
|
||||
|
||||
### Data model (sketch)
|
||||
|
||||
```prisma
|
||||
model Campus {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
address String?
|
||||
isActive Boolean @default(true)
|
||||
order Int @default(0)
|
||||
events Event[]
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
address String?
|
||||
contactName String?
|
||||
contactEmail String?
|
||||
contactPhone String?
|
||||
colour String?
|
||||
logoUrl String?
|
||||
defaultLocation String?
|
||||
order Int @default(0)
|
||||
archivedAt DateTime? // archive instead of delete when it has events
|
||||
events Event[]
|
||||
notifyRecipients User[] // daily summary for this campus's events
|
||||
}
|
||||
// Event.campusId String? (null = all campuses)
|
||||
// User.homeCampusId String?
|
||||
// UserCampusScope { userId, campusId } — empty = unscoped
|
||||
// Event.campusId String? (null = all campuses) — 1.43.0
|
||||
// User.homeCampusId String? — 1.43.0
|
||||
// AppSetting campuses_enabled — 1.43.0
|
||||
// UserCampusScope { userId, campusId } — empty = unscoped — 1.44.0
|
||||
```
|
||||
|
||||
### Running many installs
|
||||
@@ -576,13 +658,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.
|
||||
|
||||
@@ -590,18 +672,18 @@ whether they're healthy, basic usage stats, and their **licence** status.
|
||||
|
||||
### Separate app, separate repo, separate server
|
||||
|
||||
- **Its own git repository:** `joshua/hope-events-hub` on the same Gitea
|
||||
- **Its own git repository:** `joshua/events-hub` (https://c-git.crosscode.co.za/joshua/events-hub.git) on the same Gitea
|
||||
server (c-git.crosscode.co.za), with its own semver tags, CHANGELOG and
|
||||
deploys. It is never shipped to churches, so none of its code (customers,
|
||||
licence signing key, other churches' data) ends up on a customer's server.
|
||||
- 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
|
||||
@@ -657,7 +739,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
|
||||
@@ -665,7 +749,7 @@ All later calls are signed with the install secret (HMAC) and go over HTTPS.
|
||||
| State | What the install does |
|
||||
|-------|----------------------|
|
||||
| **Active** | Everything works |
|
||||
| **Grace** (expired or Hub unreachable) | Everything works; admins see a banner "Licence needs renewing — X days left". Default 14 days |
|
||||
| **Grace** (expired or Hub unreachable) | Everything works; **only site admins** see a banner "Licence needs renewing — X days left" (on their dashboard pages — never supervisors, staff, users or the public site). Default 14 days |
|
||||
| **Suspended** | **Read-only:** no new events, no new registrations or payments. Existing tickets **still scan**, and admins can still view and **export all data** |
|
||||
| **Revoked** | Same as suspended, with a permanent banner |
|
||||
|
||||
@@ -679,7 +763,8 @@ All later calls are signed with the install secret (HMAC) and go over HTTPS.
|
||||
events people already paid for, and get its own data out (POPIA access
|
||||
rights). Suspension blocks *new* activity only.
|
||||
- **One plan for now.** Plan limits (e.g. number of campuses, WhatsApp) can
|
||||
be carried in the token later.
|
||||
be carried in the token later. The first per-licence limit comes with 2.0: the
|
||||
**monthly AI cap in rand** (§15), kept and enforced on the Hub, since the Hub meters AI.
|
||||
|
||||
### Things to be aware of
|
||||
|
||||
@@ -690,7 +775,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)
|
||||
@@ -702,7 +788,9 @@ 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 }
|
||||
```
|
||||
|
||||
---
|
||||
@@ -724,6 +812,134 @@ keep adding to.
|
||||
|
||||
---
|
||||
|
||||
## 15. AI helpers (2.0 headline)
|
||||
|
||||
Small, one-button AI helpers in the places people already work. **No chat** in 2.0:
|
||||
each helper is one request with a predictable cost, and a person always reviews
|
||||
the result before anything is saved or sent.
|
||||
|
||||
**Who:** Supervisors and admins (the people who create events and read reports).
|
||||
|
||||
### The helpers
|
||||
|
||||
| Helper | Where | What it does |
|
||||
|--------|-------|--------------|
|
||||
| **Draft an event** | Event wizard, first step: "Describe your event" | *"Youth camp 14–16 March at Camp Wartburg, R450, 60 places, kids need consent"* → fills in title, description, dates/sessions, location, ticket options and prices, capacity, consent settings. The supervisor checks every field and saves as usual |
|
||||
| **Summarise this report** | Every report (Reports page) | A few plain-language insights under the report: trends, outliers, comparisons with the previous period |
|
||||
| **Draft a message** | Email / WhatsApp attendees, broadcasts | Turns a short note (*"remind them to bring a sleeping bag, bus leaves 7:30"*) into a friendly message in the right tone and length (shorter for WhatsApp), with placeholders like `{{name}}` |
|
||||
| **Summarise feedback** | Post-event feedback results | Themes, what people liked, what to improve, notable comments |
|
||||
| **Improve this text** | Event description, banner, message boxes | Rewrite for clarity, fix spelling, shorten |
|
||||
|
||||
Every helper shows **"AI suggestion — check before saving"**, can be undone, and
|
||||
never saves, sends or publishes by itself.
|
||||
|
||||
### Provider and model
|
||||
|
||||
- **One provider: Claude (Anthropic), model Claude Haiku 5.5** — the cheapest
|
||||
current model ($0.10 per million input tokens, $0.50 per million output, for
|
||||
prompts under 100K tokens; prices as of 2026-10). Good enough for drafting and
|
||||
summarising; a helper can move to a bigger model later if quality needs it.
|
||||
- **Rough cost:** a helper call is ~2,000–10,000 tokens in and ~500 out, so well under
|
||||
US$0.002 per click — about R1.50–R3 per thousand clicks. Fixed instructions are
|
||||
prompt-cached, which makes repeat calls cheaper still.
|
||||
- Structured output (a JSON schema) for "Draft an event", so the reply maps straight
|
||||
onto the event form and is validated before it's shown.
|
||||
|
||||
### Key, caps and billing — through the Hub
|
||||
|
||||
Joshua provides the API key; churches don't need their own. The key must never sit on
|
||||
an install (one leaked install would leak it for everyone), so:
|
||||
|
||||
- **Installs call AI through the Hub:** `POST /api/ai/complete` on the Hub, signed with
|
||||
the install secret like the heartbeat. The Hub adds the key, calls Claude, and returns
|
||||
the result. Still **install → Hub only** — the Hub never reaches into an install.
|
||||
- **The Hub meters every call** in rand: tokens used × the model's price × an exchange rate
|
||||
Joshua sets in the Hub (with an optional margin). Usage is kept per install per month.
|
||||
- **Monthly rand cap per licence**, set by Joshua in the Hub to match what the church pays
|
||||
(e.g. R0 = AI off, R50, R200). The licence's cap is the first per-plan limit. At 80% the
|
||||
install's admins get a warning email; at 100% the helpers are switched off until the next
|
||||
month (or until Joshua raises the cap). Optional per-user daily limit against runaway use.
|
||||
- **Admins see their usage** in Site settings → AI: used this month (R and number of uses),
|
||||
the cap, a daily chart, use per helper and per person, and an on/off switch per helper.
|
||||
- If the Hub or Claude is unavailable, the buttons say so; nothing else in the app is affected.
|
||||
|
||||
### POPIA
|
||||
|
||||
- Anthropic is added to the **operator register** (and Joshua's Hub, which relays the
|
||||
requests) and to the privacy notice: processing outside South Africa (POPIA s72),
|
||||
for drafting and summarising only.
|
||||
- **Send as little personal information as possible:** reports and feedback are sent as
|
||||
counts, totals and anonymised comments — no names, contact details, medical or
|
||||
children's information. Drafting an event or a message needs none.
|
||||
- The Hub stores **no prompt or response text** — only the install, helper, tokens, cost
|
||||
and time. Check Anthropic's commercial terms on retention and training before launch.
|
||||
- Uses that include personal information (e.g. feedback comments) go in the access log.
|
||||
|
||||
### Plumbing
|
||||
|
||||
- Install: `backend/src/ai/` — one client for the Hub's AI endpoint, one prompt + schema
|
||||
per helper, usage shown from the Hub. Feature flag per helper, all off when the licence
|
||||
has no AI or the Hub isn't connected.
|
||||
- Hub: the AI proxy, metering, caps, warnings, an exchange-rate setting, and a per-install
|
||||
usage view for Joshua (alongside the existing install detail page).
|
||||
- A small **eval set** (example event descriptions, reports, notes, with what a good answer
|
||||
looks like) to check quality when changing the prompt or model.
|
||||
- Help content, audit log entries (AI settings changed), CHANGELOG, FEATURES.md, SETTINGS.md.
|
||||
|
||||
---
|
||||
|
||||
## 16. AI: later stages
|
||||
|
||||
Ideas for after 2.0, in rough order. **Not tied to versions** — each is decided when it's
|
||||
picked up, and all of them reuse the Hub proxy, metering and caps from §15.
|
||||
|
||||
### Ask AI (read-only assistant)
|
||||
|
||||
A chat panel for supervisors and admins: *"How many paid for Camp compared with last
|
||||
year?"*, *"Which events this month are under half full?"*, *"Who hasn't paid yet for
|
||||
Men's Breakfast?"*.
|
||||
|
||||
- The AI can only call **tools the app defines** — the existing stats and report queries,
|
||||
"find an event", "registrations by week" — never its own SQL. It runs with the
|
||||
logged-in person's permissions, so it sees nothing they couldn't.
|
||||
- Answers show the numbers they're based on and link to the matching report.
|
||||
- Costs more per question than a button (several tool calls), so it counts against the same
|
||||
cap; may need a bigger model for harder questions.
|
||||
|
||||
### Help and navigation assistant
|
||||
|
||||
*"How do I add a coupon?"*, *"Where do I see who checked in?"* → a short answer from the
|
||||
in-app help content (`frontend/src/content/help/`) with a link straight to the right page.
|
||||
No personal information involved, so it's cheap and simple — could also be offered to
|
||||
ordinary users.
|
||||
|
||||
### Actions with confirmation
|
||||
|
||||
The Ask assistant can also *do* things — create an event draft, schedule a reminder, offer
|
||||
waitlist places, record a cash payment — always as **"here's what I'll do → Confirm"**, with
|
||||
each confirmed action in the audit log.
|
||||
|
||||
### More helpers
|
||||
|
||||
Smaller one-button additions as they come up: suggest an event's price tiers from past
|
||||
events, write a post-event thank-you, draft certificate wording, translate a message into
|
||||
Afrikaans (ties in with §10), flag unusual payments for the cashup.
|
||||
|
||||
### Self-hosted AI (long term)
|
||||
|
||||
Run an open-weight model on Joshua's own GPU server instead of a paid API, once usage and
|
||||
income justify the hardware:
|
||||
|
||||
- Because every install already talks to the **Hub's** AI endpoint, moving to self-hosting
|
||||
is a change on the Hub only — the Hub gets a provider adapter (Claude now, self-hosted
|
||||
later, or both: simple helpers self-hosted, harder questions on Claude).
|
||||
- **POPIA bonus:** the data can stay in South Africa — no cross-border transfer.
|
||||
- **When it makes sense:** compare the monthly API bill with the cost of a GPU server
|
||||
(hardware or cloud rental, power, upkeep), and check the self-hosted model against the
|
||||
eval set from §15 before switching any helper over.
|
||||
|
||||
---
|
||||
|
||||
## Suggested build order
|
||||
|
||||
| Phase | Version | Features | Why |
|
||||
@@ -733,14 +949,14 @@ keep adding to.
|
||||
| 2 | 1.34.0 ✅ | CI + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
|
||||
| 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 |
|
||||
| 12 | **2.0.0** | v1 API removed, migration guide, marketing site/docs for selling | The release |
|
||||
| 5 | 1.38.0 ✅ 1.39.0 ✅ 1.40.0 ✅ | **POPIA toolkit** (1.38.0: user privacy centre, unsubscribe, WhatsApp STOP, data requests inbox; 1.39.0: privacy notice editor + versions, operator register, consent log, access log; 1.40.0: retention jobs, breach register, children’s-data flags, readiness page) | Main selling point; retention jobs use the job queue |
|
||||
| 6 | 1.41.0 ✅ – 1.42.0 ✅ + Hub 1.0 ✅ – 1.1 ✅ | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo); rename to Cross Events (1.41.3); Request update (1.42.0 / Hub 1.1.0) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
|
||||
| 7 | 1.43.0 ✅ – 1.44.0 ✅ | **Multi-campus**: 1.43.0 campuses (settings, events, public filter and landing pages, home campus, filters, messages, summaries); 1.44.0 campus-scoped staff | Kids rooms and volunteer roles are per campus, so campuses come first; scoping is split out because it's security-heavy |
|
||||
| 8 | 1.45.0 ✅ – 1.46.0 ✅ | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
|
||||
| 9 | 1.47.0 – 1.48.0 | Event series & cloning, Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
|
||||
| 10 | 1.49.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
|
||||
| 11 | 1.52.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/`),
|
||||
audit log entries, Jest tests, a CHANGELOG entry, and updates to FEATURES.md
|
||||
@@ -761,7 +977,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/hope-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" |
|
||||
@@ -771,8 +987,13 @@ feature.
|
||||
| — | Hub domain | `events-hub.crosscode.co.za` (2026-10-23) |
|
||||
| — | Licence keys | Generated by the Hub when Joshua clicks "Generate" on a customer (2026-10-23) |
|
||||
| — | Grace period / suspension | 14-day grace; suspended = read-only (tickets still scan, data export still works) (2026-10-23) |
|
||||
| — | Grace banner, Hub repo, Hub development | The grace-period banner shows **only to site admins**. The Hub repo is `joshua/events-hub` (https://c-git.crosscode.co.za/joshua/events-hub.git); it is developed locally first, then deployed by Joshua (2026-10-24) |
|
||||
| — | Income in the heartbeat | Included, as **ranges** only (R0–R1 000, R1 001–R5 000, …), never exact amounts (2026-10-23) |
|
||||
| — | Licence plans | One plan for now (2026-10-23) |
|
||||
| — | AI in 2.0 | The **headline feature of 2.0**: one-button helpers only (draft an event, summarise reports and feedback, draft messages, improve text) — no chat yet (2026-10-24) |
|
||||
| — | AI provider and key | One provider, **Claude** (Haiku 5.5 to start, for cost). Joshua provides the key; installs call AI **through the Hub**, which holds the key and meters usage (2026-10-24) |
|
||||
| — | AI cost limits | A **monthly rand cap per install**, set in the Hub per licence to match what the church pays; admins see their usage in Site settings → AI (2026-10-24) |
|
||||
| — | AI later | Ask AI (read-only, tools only), help/navigation assistant, actions with confirmation, more helpers, and long-term **self-hosted** AI behind the same Hub endpoint — not tied to versions (2026-10-24) |
|
||||
| — | POPIA: message kinds | Bulk messages are either **Event updates** (to registrants — still sent after unsubscribing) or **News & announcements** (marketing — opt-in, one-click unsubscribe). Every bulk email links to the preferences (2026-10-23) |
|
||||
| — | POPIA: existing users | Kept opted in to news (logged as "existing account"), asked once at their next login to confirm or opt out. New sign-ups choose with an unticked box (2026-10-23) |
|
||||
| — | POPIA: deleting an account | A request: the account is deactivated at once, personal info is anonymised when an admin approves. Waits while the person has an open registration for an upcoming event (2026-10-23) |
|
||||
@@ -780,6 +1001,13 @@ feature.
|
||||
| — | POPIA: privacy notice | Today's notice becomes the starting template, one editable box per section; org name, Information Officer, retention periods and the operator list fill in automatically. Publishing saves a numbered version (2026-10-23) |
|
||||
| — | POPIA: re-accepting the notice | The admin decides per version ("significant change — ask everyone to accept again"); people are asked once after login (2026-10-23) |
|
||||
| — | POPIA: inactive accounts | Anonymised after 5 years without a login or registration, **on by default**, with a warning email 30 days before; staff/supervisor/admin accounts never (2026-10-23) |
|
||||
| — | Product name | **Cross Events** (was Hope Events), Hub: **Cross Events Hub**. UI text and docs renamed in 1.41.3; technical identifiers (`hope-events` repo, storage keys, encryption salt, migrations, PM2/nginx names) stay, so installs and sign-ins keep working (2026-10-27) |
|
||||
| — | Campus-scoped staff (1.44.0) | Scoped staff: their campuses' events plus "All campuses" events, which they can view, scan, take door payments and do manual registrations for, but not set up, cash up or refund. Banner, provider reconciliation and unlinked donations: unscoped only; broadcasts limited to their campuses' audiences and their events' attendees; coupons only for their events. Other campuses' records answer "not found" except the scanner, which names the campus. Scopes ignored while campuses are off, with a warning (2026-10-27) |
|
||||
| — | Multi-campus split | Two releases: **1.43.0 campuses**, **1.44.0 campus-scoped staff**; everything after moves up by one. Scoped staff can view and scan "All campuses" events, only admins and unscoped supervisors edit them. "Everyone at campus X" = home campus X, plus (tick box) registrants of campus X's events in the last 12 months. Campus landing pages are in 1.43.0 (2026-10-27) |
|
||||
| — | Updates | Sites show **Request update**, never the deploy command: the request goes to the Hub, which tells Joshua; he updates the server. 1.42.0 / Hub 1.1.0; Multi-campus and everything after moves up by one (2026-10-27) |
|
||||
| — | Wallet passes | **Removed from scope** (2026-10-28); everything after volunteer rosters moves up by one |
|
||||
| — | Kids check-in (1.45.0) | One release. **Fully digital** — no label printers: pickup code on the parent's phone, a "write on the sticker" panel for blank stickers. Staff desk only (no kiosk). Anyone with the pickup code can collect; families list authorised adults without accounts. Child profiles deleted after 6 months without a check-in (2026-10-28) |
|
||||
| — | Volunteer rosters (1.46.0) | Anyone with an account can volunteer. Roles are per event (or per session) — rosters cover one event/session and never repeat. A swap needs no approval: it happens once someone accepts, and the event's notify recipients are emailed. Volunteers check in by scanning a QR code or are checked in by staff. The free volunteer ticket is off by default and chosen per event. A role linked to a kids room counts its checked-in volunteers as leaders on duty for the room's ratio (2026-10-28) |
|
||||
| — | 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
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# Hub protocol (version 1)
|
||||
|
||||
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.
|
||||
|
||||
Principles:
|
||||
|
||||
- **Install → Hub only.** The install always starts the conversation; the Hub never calls
|
||||
into an install. Works behind firewalls, and the Hub has no access to church data.
|
||||
- **No personal information** is ever sent — counts, versions and health only. Income is
|
||||
sent as a range, never an amount.
|
||||
- **A Hub outage never switches a church off.** Licences are signed tokens the install
|
||||
verifies offline with the Hub's public key.
|
||||
- Every request carries `protocolVersion`. The Hub accepts the current and the previous
|
||||
version, so installs waiting to update keep working.
|
||||
|
||||
All bodies are JSON (`Content-Type: application/json`). Times are ISO 8601 UTC strings.
|
||||
Errors are `{ "message": "...", "code": "..." }` with a 4xx/5xx status.
|
||||
|
||||
---
|
||||
|
||||
## 1. Public key
|
||||
|
||||
```
|
||||
GET /api/public-key
|
||||
→ 200 { "protocolVersions": [1], "keyId": "k1", "algorithm": "Ed25519", "publicKey": "-----BEGIN PUBLIC KEY-----\n…" }
|
||||
```
|
||||
|
||||
Installs ship with the Hub's public key built in (and may refresh it from here). The private
|
||||
key exists only on the Hub server.
|
||||
|
||||
## 2. Register an install
|
||||
|
||||
Sent once, at the end of `/setup` (licence step) or from Site settings → "Connect to Hub".
|
||||
|
||||
```
|
||||
POST /api/installs/register
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"licenceKey": "CEH-7K2M-9QXA-P4RT-WD8C",
|
||||
"install": {
|
||||
"name": "Hope Family Church",
|
||||
"url": "https://events.hopefamily.co.za",
|
||||
"adminContact": { "name": "Jane Admin", "email": "jane@hopefamily.co.za" },
|
||||
"version": "1.41.0",
|
||||
"timezone": "Africa/Johannesburg",
|
||||
"server": { "node": "20.18.0", "postgres": "16.4", "os": "linux 6.8" },
|
||||
"features": { "whatsapp": true, "paymentProviders": ["yoco", "payfast"], "campuses": false }
|
||||
}
|
||||
}
|
||||
→ 201 {
|
||||
"installId": "inst_…",
|
||||
"installSecret": "…", // shown once; the install stores it encrypted
|
||||
"licence": "<licence token>",
|
||||
"heartbeatIntervalMinutes": 15,
|
||||
"latestVersion": "1.41.0"
|
||||
}
|
||||
```
|
||||
|
||||
- `404 LICENCE_NOT_FOUND` — unknown key. `403 LICENCE_REVOKED` — revoked licences can't register.
|
||||
- One licence = one install. Registering again **from the same URL** re-registers (new
|
||||
secret, same `installId`). From a different URL → `409 LICENCE_IN_USE` until Joshua releases
|
||||
the licence in the Hub (e.g. after a move to a new domain).
|
||||
- The `adminContact` is the one piece of personal information: a work contact for the
|
||||
church's admin, so Joshua can reach them. It is not used for anything else.
|
||||
- Rate-limited per IP.
|
||||
|
||||
## 3. Heartbeat
|
||||
|
||||
Every `heartbeatIntervalMinutes` (15) from the install's job queue.
|
||||
|
||||
```
|
||||
POST /api/installs/heartbeat
|
||||
X-Hub-Install: inst_…
|
||||
X-Hub-Timestamp: 2026-10-24T08:15:00.000Z
|
||||
X-Hub-Signature: <hex HMAC-SHA256(installSecret, timestamp + "." + rawBody)>
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"version": "1.41.0",
|
||||
"health": {
|
||||
"uptimeSeconds": 86400,
|
||||
"db": true,
|
||||
"jobs": { "mode": "queue", "failedLast24h": 0 },
|
||||
"lastBackupAt": "2026-10-24T00:00:12.000Z", // null if none
|
||||
"disk": { "freeBytes": 21474836480, "totalBytes": 53687091200 }, // null if unknown
|
||||
"errorsLast24h": 0, // null if not tracked
|
||||
"pendingMigrations": 0
|
||||
},
|
||||
"stats": {
|
||||
"users": 412,
|
||||
"events": { "active": 3, "upcoming": 5, "total": 61 },
|
||||
"registrations": { "last30d": 88, "total": 2310 },
|
||||
"tickets": { "last30d": 140, "total": 3901 },
|
||||
"checkIns": { "last30d": 96 },
|
||||
"messages": { "email30d": 610, "whatsapp30d": 233 },
|
||||
"paymentProviders": ["yoco", "payfast"],
|
||||
"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" }
|
||||
}
|
||||
}
|
||||
→ 200 { "licence": "<fresh licence token>", "latestVersion": "1.42.0", "serverTime": "…" }
|
||||
```
|
||||
|
||||
- The signature covers the exact bytes sent. Requests more than **5 minutes** off the Hub's
|
||||
clock are refused (`401 STALE_REQUEST`); a bad signature is `401 BAD_SIGNATURE`; an unknown or
|
||||
released install is `404 INSTALL_NOT_FOUND` (the install should show "Reconnect to Hub").
|
||||
- **Income bands** (worked out on the install; the amount never leaves it): `R0–R1 000`,
|
||||
`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
|
||||
Ed25519 signature of the ASCII string `hel1.<payload>`.
|
||||
|
||||
```json
|
||||
{
|
||||
"v": 1,
|
||||
"licenceId": "lic_…",
|
||||
"installId": "inst_…",
|
||||
"customer": "Hope Family Church",
|
||||
"plan": "standard",
|
||||
"status": "active", // active | suspended | revoked
|
||||
"licenceExpiresAt": "2027-10-31T21:59:59.000Z", // null = no end date
|
||||
"graceDays": 14,
|
||||
"issuedAt": "2026-10-24T08:15:00.000Z",
|
||||
"validUntil": "2026-11-23T08:15:00.000Z", // issuedAt + 30 days
|
||||
"limits": { "aiMonthlyCapCents": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
### What the install does with it
|
||||
|
||||
The install keeps the newest valid token (signature checks out, `installId` matches) and works
|
||||
out its state:
|
||||
|
||||
| State | When | What happens |
|
||||
|-------|------|--------------|
|
||||
| **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: 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, 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.
|
||||
|
||||
## 5. Versioning
|
||||
|
||||
- `protocolVersion` goes up only for changes an older install can't handle. Adding optional
|
||||
fields to a heartbeat or a token doesn't need a new version — both sides ignore fields they
|
||||
don't know.
|
||||
- The Hub accepts protocol `N` and `N-1`; anything else is `400 UNSUPPORTED_PROTOCOL`.
|
||||
|
||||
## 6. Later (not in v1)
|
||||
|
||||
- **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`.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user