Compare 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 | ||
|
|
7b57fa4d04 | ||
|
|
d265047c84 | ||
|
|
40c5c9cc1d | ||
|
|
261bc8a639 | ||
|
|
594cb35a85 | ||
|
|
be5ad620da | ||
|
|
96adf20cf0 | ||
|
|
a9c7a1129b | ||
|
|
d8eb9f22cc | ||
|
|
4cae0fc672 | ||
|
|
cd33ba281e | ||
|
|
220dafb400 | ||
|
|
eb0f390215 | ||
|
|
55a7ee2970 | ||
|
|
4bc7dbc989 | ||
|
|
71b9da2458 | ||
|
|
654f51b86d | ||
|
|
aeb5d6d678 | ||
|
|
f2b51dbd88 |
@@ -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'"
|
||||
@@ -31,6 +31,9 @@ yarn-error.log*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# this install's PM2 config (made from ecosystem.config.example.js; deploys never touch it)
|
||||
/ecosystem.config.js
|
||||
|
||||
# misc scratch / generated files
|
||||
temp/
|
||||
backend/public/uploads/
|
||||
|
||||
+329
@@ -7,6 +7,335 @@ 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
|
||||
|
||||
- **POPIA toolkit, part 2: the admin side.** A new **Admin → Privacy (POPIA)** page with four tabs:
|
||||
- **Privacy notice editor.** The privacy notice is now editable. It starts from the built-in text (updated for 1.38.0's privacy page, news opt-in, unsubscribe and STOP), one box per section; add, remove and reorder sections, save drafts, preview, and **publish numbered versions**. Placeholders such as `{org}`, `{ioName}`, `{ioEmail}`, `{medicalRetentionDays}` and `{operators}` are filled in when the notice is shown, so it follows Site settings and the operator register. Earlier versions stay readable on the privacy page (`/legal/privacy?version=N`).
|
||||
- **Re-accepting the notice.** When publishing, tick "Significant change" (and say what changed) to ask everyone to accept the new version: they're asked once, after their next login, with a link to delete their account instead. Sign-up records which version was accepted. Acceptances are counted per version.
|
||||
- **Operators** — the register of outside services that handle personal information (Yoco, PayFast, WAWP, WhatsApp, email and hosting providers, Google Maps, Sentry), with what each receives and where. Those in use are listed in the notice.
|
||||
- **Consent log** — every news opt-in/opt-out (and how), every privacy notice acceptance, and every parental consent answer, searchable by person.
|
||||
- **Access log** — who downloaded, emailed or opened personal information in bulk: report PDFs/Excel files (and reports emailed or WhatsApped), attendance registers, certificate downloads, room/group lists, attendee form answers, the parental consent and medical lists, database backups, and every "view as" session. With how many records each time.
|
||||
- The contact page shows the Information Officer (when set in Site settings → Legal), with a link to the privacy notice.
|
||||
- "Download my data" includes the privacy notice versions you accepted.
|
||||
|
||||
### Changed
|
||||
|
||||
- Deleting someone's personal data also removes the IP addresses kept with their consent and notice records (the records stay as proof).
|
||||
|
||||
## [1.38.0] - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- **POPIA toolkit, part 1: privacy for the people in the system.**
|
||||
- **Privacy page** (My account → Privacy): choose News & announcements by email and/or WhatsApp, see the history of those choices, **download my data** (a readable PDF, or the complete copy as JSON: account, registrations and the people registered, form answers, parental consent, payments, tickets and check-ins, certificates, feedback, communication choices), **ask for a correction**, and **delete my account**.
|
||||
- **Two kinds of bulk message.** When emailing or WhatsApping attendees, or sending a broadcast, choose **Event update** (about an event the recipients registered for — goes to everyone selected) or **News & announcements** (only to people who opted in, with an unsubscribe link). Attendee messages default to event updates, broadcasts to news, and the next-event promo automation is always news. Previews say how many people were skipped.
|
||||
- **Unsubscribe**: every news email ends with an unsubscribe link and carries one-click unsubscribe headers (Gmail, Apple Mail); every event update says why it was sent and links to the preferences. News WhatsApps end with "Reply STOP" and a link. Unsubscribed addresses without an account are remembered too.
|
||||
- **WhatsApp STOP**: replying STOP (or "unsubscribe") to the church's number stops WhatsApp news and sends a confirmation. Needs WAWP's webhook pointed at the URL shown in Site settings → WhatsApp (it carries a secret, since WAWP doesn't sign webhooks).
|
||||
- **News opt-in**: an unticked "news and announcements" box on sign-up. Existing accounts stay opted in (logged as "existing account") and are asked once, after their next login, to confirm or opt out. Every change is logged with when and how.
|
||||
- **Data requests** (Admin → Data requests): correction and deletion requests with a 30-day due date, emailed to the Information Officer and admins, with a daily reminder for ones due within a week or overdue (`privacy` job, 08:00; `PRIVACY_JOBS_ENABLED=false` to switch off). Completing or rejecting a request emails the person and is in the audit log.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Deleting an account is now a request.** "Delete my account" (and "Also delete my personal data" when closing an account) closes the account straight away; the personal information is deleted when an admin completes the request — after any registration for an upcoming event has been cancelled or the event is over.
|
||||
- **Deleting personal data now goes much further.** Before, it only cleared the account's name, email and phone. Now it also removes the names and details of the people they registered (and their own details on other people's bookings), form answers, parental-consent details, sponsor names, feedback authorship, pending transfer requests, certificate names, login history, and payer names/emails kept from Yoco and PayFast notifications. Payments, registrations, tickets and check-ins are kept, as "Deleted User", for the books and reports. Admin → Users → Anonymise uses the same deletion, and is refused while the person has a registration for an upcoming event.
|
||||
- "Download my data" is blocked while an admin is viewing as someone else.
|
||||
|
||||
## [1.37.4] - 2026-10-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Deploy script** (`scripts/deploy.sh`): without a tag it now deploys the latest `main`. The PM2 config is per install: `ecosystem.config.js` is no longer in git (the repo has `ecosystem.config.example.js`), so deploys stop overwriting the install's app names and port; the script keeps the install's copy across checkouts and rollbacks, and creates it from the example on a fresh install. The script also runs from a temporary copy (so checking out a new version of it mid-deploy is safe), and a failure rolls back once.
|
||||
|
||||
## [1.37.3] - 2026-10-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- The production build (and so the deploy) failed on unescaped quotes in the Payments help text.
|
||||
|
||||
## [1.37.2] - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- **Payment status checks** for when a provider's notification never arrives. Payments → Reconcile has a new "Waiting for payment" list: online checkouts from the last 14 days with no payment yet, each with "Check status" (and "Check all"). For PayFast it looks the payment up in PayFast's transaction history and, if PayFast received it, records it exactly as the notification would have (same amount check, tickets and emails). A background job does this every 15 minutes for checkouts between 10 minutes and 3 days old (`payment-status`, switch off with `PAYMENT_STATUS_JOB_ENABLED=false`). Yoco's API has no way to look a payment up, so Yoco checkouts show "can't check" — find them in the Yoco portal and record them with Reconcile, as before.
|
||||
- **Test connection** on the PayFast settings card: checks the saved merchant ID and passphrase with PayFast.
|
||||
|
||||
## [1.37.1] - 2026-10-23
|
||||
|
||||
### Changed
|
||||
|
||||
- **No "Pay online" when it can't be used.** When no payment provider is switched on, or the amount is under the R15 minimum, the registration success page, My Events ("Make payment" and "Make donation"), the donate page, "Sponsor a spot", the donation box on the register form and the supervisor Payment Link tab no longer offer online payment. Where it helps, they say why and to pay at the event instead.
|
||||
- Roadmap: SnapScan and Zapper are dropped (Yoco and PayFast are enough); later releases move up two versions — POPIA toolkit is next as 1.38.0.
|
||||
|
||||
## [1.37.0] - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- **PayFast.** A second online payment provider (card, instant EFT and more). Switch it on in Site settings → Payments, then enter the merchant ID, merchant key and salt passphrase from your PayFast (or Sandbox) dashboard and choose Sandbox or Live. Payments are confirmed by PayFast's notification (ITN) at `/api/webhooks/payfast`, which is checked the way PayFast requires: the signature, that it came from PayFast, that PayFast confirms it, and that the amount matches the checkout. PayFast starts switched off.
|
||||
- **Choosing how to pay.** When more than one provider is on, payers choose on a new "Pay online" page (`/pay/…`). Payment links in emails, WhatsApp and invoices go there too, so they work for every provider.
|
||||
- Payer-facing wording no longer says Yoco: "Pay online" after registering, a neutral redirect message, and the Terms and Privacy pages name both providers (Yoco or PayFast), including that PayFast may receive your name and email to fill in its payment page.
|
||||
- A payment of a different amount from the checkout (e.g. changed along the way) isn't recorded automatically: it waits in "Unreconciled Online Payments" with a note explaining why.
|
||||
|
||||
## [1.36.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. |
|
||||
+230
-19
@@ -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,11 +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. [Help system](#help-system)
|
||||
18. [Background jobs](#background-jobs)
|
||||
19. [Operations](#operations)
|
||||
20. [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)
|
||||
|
||||
---
|
||||
|
||||
@@ -203,6 +207,14 @@ Feedback → Attendance**.
|
||||
Site Settings → Payments, where providers are switched on or off and put in order
|
||||
(the first one is the default). Online payments are at least R15; payments are
|
||||
recorded at the full amount and provider fees are dealt with at cashup.
|
||||
- **PayFast** — the second online provider (card, instant EFT…), with sandbox and live
|
||||
modes. When several providers are on, payers choose on the "Pay online" page; a
|
||||
payment of a different amount from the checkout is held for review instead of being
|
||||
recorded. When no provider is on (or the amount is under R15), nothing offers to pay
|
||||
online.
|
||||
- **Payment status checks** — if a provider's notification never arrives, Payments →
|
||||
Reconcile → "Waiting for payment" checks with the provider (PayFast; Yoco's API can't be
|
||||
asked) and records what it finds. A job checks recent PayFast checkouts every 15 minutes.
|
||||
- **Payment links** — supervisors can generate and send a Yoco link for any
|
||||
amount (including partial).
|
||||
- **Manual payments** — cash, card, EFT, voucher or other, recorded by staff.
|
||||
@@ -335,6 +347,8 @@ Every user picks a **notification preference**: Email, WhatsApp, or Both.
|
||||
outstanding balance).
|
||||
- **Broadcasts** — ad-hoc email/WhatsApp to any users or addresses, not tied to
|
||||
one event.
|
||||
- **Message type** — Event update or News & announcements; news only reaches people
|
||||
who opted in (see [Privacy](#privacy-popia)).
|
||||
- **Automations** — schedule a sequence of lifecycle emails for an event
|
||||
(pre-event, final reminder, thank-you, promo for another event).
|
||||
- **Manage scheduled** — view, edit or delete queued jobs per channel. Sends
|
||||
@@ -382,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).
|
||||
|
||||
---
|
||||
|
||||
@@ -436,6 +454,8 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
- **Manage users** — server-side search and filters (role, active), paging,
|
||||
edit role and notification preference, revoke sessions, delete or
|
||||
**anonymise** personal data.
|
||||
- **Data requests** — POPIA correction and deletion requests (see
|
||||
[Privacy](#privacy-popia)).
|
||||
- **Registrations** — all registrations, with filters (event, status, text),
|
||||
status counts, revenue and outstanding totals, and per-registration options,
|
||||
form answers and payments (with who recorded each one).
|
||||
@@ -498,18 +518,125 @@ 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 |
|
||||
| Legal | Operator, Information Officer, website, effective date for the Terms/Privacy pages |
|
||||
| WhatsApp | WAWP credentials, connect a number, status/restart/logout |
|
||||
| Payments | Online payment providers: Enabled toggle and order; Yoco's secret key (masked, test/live), webhook secret (generate by registering with Yoco, or enter), list/delete the account's webhooks |
|
||||
| Payments | Online payment providers: Enabled toggle and order; PayFast merchant ID/key, passphrase and sandbox/live; Yoco's secret key (masked, test/live), webhook secret (generate by registering with Yoco, or enter), list/delete the account's webhooks |
|
||||
| Backups | List, run now, download database backups |
|
||||
|
||||
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
|
||||
@@ -528,13 +655,78 @@ See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
|
||||
(`TRUST_PROXY` to read real IPs behind a proxy).
|
||||
- **Security event log** — logins, password changes and resets, with device
|
||||
and IP.
|
||||
- **Account closure** — deactivate, or anonymise personal data while keeping
|
||||
financial records.
|
||||
- **Account closure** — deactivate, or close and ask for the personal data to be
|
||||
deleted (see [Privacy](#privacy-popia)).
|
||||
- **Upload hardening** — server-generated filenames, and uploads served with a
|
||||
sandbox CSP and `nosniff`.
|
||||
|
||||
---
|
||||
|
||||
## Privacy (POPIA)
|
||||
|
||||
Tools that help a church handle personal information the way POPIA expects. They
|
||||
support compliance; wording and retention choices are for the church's Information
|
||||
Officer.
|
||||
|
||||
**For everyone** — the **Privacy** page (`/dashboard/user/privacy`):
|
||||
|
||||
- **News & announcements** opt-in per channel (email, WhatsApp), with the history of
|
||||
every choice: when, and how (sign-up, privacy page, unsubscribe link, STOP reply…).
|
||||
Sign-up has an unticked opt-in box; accounts that never answered are asked once
|
||||
after logging in.
|
||||
- **Download my data** — readable PDF, or the complete copy as JSON.
|
||||
- **Correct my data** — profile fields directly; anything else as a correction request.
|
||||
- **Delete my account** — closes the account at once and files a deletion request.
|
||||
|
||||
**Bulk messages** are either an **Event update** (about an event the recipients
|
||||
registered for; always sent) or **News & announcements** (only to people who opted in
|
||||
on that channel and haven't unsubscribed). News emails carry an unsubscribe link and
|
||||
one-click `List-Unsubscribe` headers; news WhatsApps end with "Reply STOP" and a link.
|
||||
Event updates say why they were sent and link to the preferences. A STOP reply on
|
||||
WhatsApp turns off WhatsApp news (through WAWP's `message` webhook, checked against a
|
||||
secret in the webhook URL).
|
||||
|
||||
**Privacy notice** (`/legal/privacy`) — edited in **Admin → Privacy (POPIA)**
|
||||
(`/dashboard/admin/privacy`): sections start from the built-in text; drafts, preview,
|
||||
and published, numbered versions (earlier ones stay readable with `?version=N`).
|
||||
Placeholders (`{org}`, `{ioName}`, `{ioEmail}`, `{website}`, `{orgEmail}`,
|
||||
`{medicalRetentionDays}`, `{operators}`) are filled in from Site settings and the
|
||||
operator register. A version marked as a significant change is accepted once by everyone
|
||||
after their next login; sign-up records the version accepted. The contact page shows the
|
||||
Information Officer.
|
||||
|
||||
**Registers and logs** (same admin page):
|
||||
|
||||
- **Operators** — the services that process personal information (payment, WhatsApp,
|
||||
email, hosting, maps, error monitoring), what each receives and where; those in use
|
||||
are listed in the notice.
|
||||
- **Consent log** — news opt-ins/opt-outs, privacy notice acceptances and parental consent
|
||||
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. 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
|
||||
admins, reminded daily when due within a week. Completing a deletion anonymises the
|
||||
person: name, contact details, the people they registered, form answers, consent
|
||||
details, sponsor names, login history and payer details in provider notifications are
|
||||
removed; payments, registrations, tickets and check-ins stay as "Deleted User". It
|
||||
waits while they have a registration for an upcoming event. Everything is in the audit
|
||||
log.
|
||||
|
||||
---
|
||||
|
||||
## Help system
|
||||
|
||||
A floating **"Need help?"** button on every page opens a page-specific guide
|
||||
@@ -558,9 +750,14 @@ can't start, the same jobs run on in-process timers.
|
||||
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h (not on the queue) |
|
||||
| `daily-summary` — daily event summary email | 07:00 daily (`DAILY_SUMMARY_ENABLED=false` to disable) |
|
||||
| `waitlist` — expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false`) |
|
||||
| `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 |
|
||||
@@ -569,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
|
||||
@@ -580,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).
|
||||
|
||||
---
|
||||
@@ -595,10 +802,12 @@ 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 |
|
||||
| `/payment/success`, `/payment/failure`, `/payment/cancel` | Public | Yoco return pages |
|
||||
| `/pay/[id]` | Public | Pay online: choose a provider, or go on to PayFast |
|
||||
| `/payment/success`, `/payment/failure`, `/payment/cancel` | Public | Return pages after paying online |
|
||||
| `/forms` | Public / User | Attendee forms |
|
||||
| `/contact`, `/legal/terms`, `/legal/privacy`, `/lockdown-rules` | Public | Info pages |
|
||||
| `/login`, `/register`, `/forgot-password`, `/reset-password`, `/activate-account` | Public | Accounts |
|
||||
@@ -606,9 +815,11 @@ can't start, the same jobs run on in-process timers.
|
||||
| `/consent/[token]` | Parent/guardian | Give or decline parental consent |
|
||||
| `/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`, `/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/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/settings`) | Admin | Admin 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,14 +92,44 @@ 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
|
||||
|
||||
To update a running install to a release: `bash scripts/deploy.sh v1.34.0` on the server
|
||||
(backs up the database, migrates, builds, reloads PM2).
|
||||
### 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`
|
||||
- `bash scripts/deploy.sh v1.37.4` — a tagged release
|
||||
|
||||
It backs up the database, checks the version out, installs, migrates, builds and reloads PM2.
|
||||
If anything fails before the reload, it goes back to the previous version (the running app
|
||||
isn't touched).
|
||||
|
||||
PM2 uses the install's own `ecosystem.config.js` (app names, frontend port). It isn't in git,
|
||||
so deploys never change it; the first deploy creates it from `ecosystem.config.example.js` —
|
||||
set names that match your PM2 apps (`pm2 ls`) and the port you use.
|
||||
|
||||
### Backend Deployment
|
||||
|
||||
|
||||
+37
-1
@@ -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
|
||||
|
||||
@@ -63,6 +64,9 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `payfast_merchant_id` / `payfast_merchant_key` | `PAYFAST_MERCHANT_ID` / `PAYFAST_MERCHANT_KEY` env vars, else *(blank)* | From the PayFast (or Sandbox) dashboard. Both are needed before PayFast can take payments. The key isn't secret (it's in the payment form), so it's shown in full. Managed through `/api/payfast`. |
|
||||
| `payfast_passphrase` | `PAYFAST_PASSPHRASE` env var, else *(blank)* | Stored encrypted. Must match the dashboard's salt passphrase exactly, or PayFast refuses payments ("signature does not match"). |
|
||||
| `payfast_mode` | `PAYFAST_MODE` env var, else `sandbox` | `sandbox` or `live`: where payers are sent and where notifications are confirmed. |
|
||||
| `payment_providers` | Yoco enabled | JSON `{ order: [...], enabled: { yoco: true } }`: which online payment providers are on, and their order (the first one enabled and set up is the default). Providers not listed yet come last, switched off (Yoco on, as it was the only provider). If none is on and set up, online payments are off. |
|
||||
| `yoco_secret_key` | `YOCO_SECRET_KEY` env var, else *(blank)* | Stored AES-256-GCM encrypted. If blank, online checkouts and sponsorships fail with "online payments are not configured". |
|
||||
| `yoco_webhook_secret` | `YOCO_WEBHOOK_SECRET` env var, else *(blank)* | Stored encrypted. If blank, Yoco webhooks are rejected, so payments aren't confirmed automatically. |
|
||||
@@ -76,16 +80,48 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
|
||||
| `legal_io_name` | `"[not yet configured]"` | Same reasoning as `legal_operator_name`. |
|
||||
| `legal_io_email` | `"io@example.com"` | Rendered as a `mailto:` link, so the placeholder stays a syntactically valid address on the IANA-reserved `example.com` domain rather than a bracketed non-email string that would break the link. |
|
||||
| `legal_website_url` | `"example.com"` | Same reasoning — used to build an `https://` link. |
|
||||
| `legal_effective_date` | `"[not yet set]"` | Plain text only, no link — a bracketed placeholder is fine here. |
|
||||
| `legal_effective_date` | `"[not yet set]"` | Plain text only, no link — a bracketed placeholder is fine here. Only used by the built-in privacy notice: once a version is published in Admin → Privacy, its publish date is the effective date. |
|
||||
| `notify_user_on_impersonation` | `"false"` | When `"true"`, a user is emailed (with the reason) whenever an admin views their account with Impersonate. Set under Site Settings → Legal → Support access. |
|
||||
| `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 |
|
||||
|---|---|---|
|
||||
| `WAWP_ACCESS_TOKEN` / `WAWP_INSTANCE_ID` | `WAWP_ACCESS_TOKEN`/`WAWP_INSTANCE_ID` env vars, else *(blank)* | No hardcoded default — WhatsApp sending is simply unavailable until both are configured (Admin → Site Settings → WhatsApp). |
|
||||
| `WAWP_WEBHOOK_SECRET` | Generated (random) the first time the WhatsApp tab is opened | Part of the webhook URL shown in Site Settings → WhatsApp (`/api/whatsapp/webhook?secret=…`). WAWP doesn't sign webhooks, so incoming messages (STOP replies) are only acted on when the secret matches. Not editable in the UI; delete the row to get a new one (then update the URL in WAWP). The URL uses `BACKEND_URL`. |
|
||||
|
||||
## Privacy (POPIA)
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `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.
|
||||
|
||||
## Backups
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -39,6 +43,16 @@ FRONTEND_URL=http://localhost:3000
|
||||
# YOCO_SECRET_KEY=sk_test_...
|
||||
# YOCO_WEBHOOK_SECRET=whsec_...
|
||||
|
||||
# ─── Payments (PayFast) — optional fallbacks ──────────────────────────────────
|
||||
# Preferred: Admin → Site Settings → Payments → PayFast. Only used if the database has no value.
|
||||
# PAYFAST_MERCHANT_ID=
|
||||
# PAYFAST_MERCHANT_KEY=
|
||||
# PAYFAST_PASSPHRASE=
|
||||
# PAYFAST_MODE=sandbox
|
||||
# PayFast notifications (ITNs) must come from a PayFast address. Behind a reverse proxy, set
|
||||
# TRUST_PROXY so the real address is seen. Only for testing through a tunnel:
|
||||
# PAYFAST_SKIP_IP_CHECK=true
|
||||
|
||||
# ─── Public URLs ──────────────────────────────────────────────────────────────
|
||||
# Used to build links in emails (password reset, activation, etc.)
|
||||
APP_BASE_URL=https://your-frontend-domain.com
|
||||
@@ -59,6 +73,22 @@ CONSENT_JOBS_ENABLED=true
|
||||
# Waitlist: expire unclaimed offers and offer freed places (every 10 minutes by default)
|
||||
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.
|
||||
|
||||
@@ -18,6 +18,7 @@ Tokens are issued by `POST /api/users/login`.
|
||||
- [Event Options & Variants](#event-options--variants)
|
||||
- [Event Attachments](#event-attachments)
|
||||
- [Registrations](#registrations)
|
||||
- [Privacy (POPIA)](#privacy-popia)
|
||||
- [Coupons](#coupons)
|
||||
- [Parental consent](#parental-consent)
|
||||
- [Waitlist](#waitlist)
|
||||
@@ -43,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)
|
||||
@@ -550,6 +552,55 @@ Check-in rules:
|
||||
- `POST /api/tickets/scan/:qrCode` returns `403 { code: "CONSENT_REQUIRED" }` unless consent is given.
|
||||
- Supervisors can pass `overrideConsent: true`, which is audited as `consent_override`.
|
||||
|
||||
## Privacy (POPIA)
|
||||
|
||||
The data subject's tools, unsubscribe links and the admin data-requests inbox (`src/utils/privacy.js`, `src/controllers/privacyController.js`).
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/privacy/me` | user | `{ news: { email, whatsapp }, newsAskedAt, canWhatsApp, notificationPreference, history, requests }` |
|
||||
| PUT | `/api/privacy/me/news` | user | `{ email?: bool, whatsapp?: bool, source?: "prompt" }` — change the news opt-in (WhatsApp needs a valid phone). Logged in `CommunicationConsent`; `source: "prompt"` is the one-time question after login |
|
||||
| GET | `/api/privacy/me/export` | user | Download my data as JSON (`?format=pdf` for a readable PDF). Recorded as a completed self-service `access` request. 403 while impersonating; rate-limited |
|
||||
| POST | `/api/privacy/me/requests` | user | `{ type: "correction", details }` or `{ type: "deletion", password, details? }`. Deletion closes the account at once (deactivated, sessions revoked, news off). 409 if one of that type is already open. Emails the Information Officer + admins |
|
||||
| GET | `/api/privacy/unsubscribe/:token` | public (signed token) | What the link is for: an account (masked email, news state) or an address |
|
||||
| POST | `/api/privacy/unsubscribe/:token` | public (signed token) | `{ channels?: ["email","whatsapp"] }` — turn news off (default both) |
|
||||
| POST | `/api/privacy/unsubscribe/:token/one-click` | public (signed token) | RFC 8058 one-click (from the `List-Unsubscribe` header) — email news off. GET redirects to `/unsubscribe` |
|
||||
| GET | `/api/privacy/requests?status=open\|closed\|all` | admin | Data requests (not self-service downloads), with `overdue` and, for open deletions, `blockers` (registrations for events that haven't ended) |
|
||||
| POST | `/api/privacy/requests/:id/complete` | admin | `{ response? }` (required for corrections; emailed). Deletion: anonymises the account — 409 while it has blockers. Audited as `data_request_completed` (+ `user_anonymised`) |
|
||||
| POST | `/api/privacy/requests/:id/reject` | admin | `{ response, cancelled?: bool }` — reject (or mark withdrawn); a deletion reopens the account. Audited as `data_request_rejected` |
|
||||
|
||||
Privacy notice, operators and logs (`src/utils/privacyNotice.js`, `src/utils/accessLog.js`, `src/controllers/privacyAdminController.js`):
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/privacy/notice?version=N` | public | The notice (current, or version N) with placeholders filled in: `{ version, builtIn, current, effectiveDate, changeSummary, sections: [{ id, title, body }], versions }`. Version 0 = the built-in notice, only while nothing is published |
|
||||
| POST | `/api/privacy/me/notice/accept` | user | Accept the current version (recorded in `NoticeAcceptance`, `User.noticeVersionAccepted`). 403 while impersonating |
|
||||
| GET | `/api/privacy/notice/draft` | admin | Editor state: `sections` (raw, with placeholders), `rendered`, `isDraft`, `savedAt`, `versions` (with acceptance counts), `builtIn` (the built-in sections), `placeholders` |
|
||||
| PUT | `/api/privacy/notice/draft` | admin | `{ sections }` — save the draft (AppSetting `privacy_notice_draft`) |
|
||||
| DELETE | `/api/privacy/notice/draft` | admin | Discard the draft |
|
||||
| POST | `/api/privacy/notice/publish` | admin | `{ sections, changeSummary?, requiresAcceptance? }` — publish the next version and clear the draft. `requiresAcceptance` needs a `changeSummary`. Audited as `privacy_notice_published` |
|
||||
| GET / POST | `/api/privacy/operators` | admin | The operator register / add one `{ name, purpose, dataShared?, location?, website?, active? }` |
|
||||
| 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` |
|
||||
|
||||
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`.
|
||||
|
||||
Notes:
|
||||
- Requests are due 30 days after they're made. The `privacy` job (08:00, `PRIVACY_JOBS_ENABLED=false` to disable) emails the Information Officer (`legal_io_email`) and admins about open requests due within 7 days or overdue.
|
||||
- **Anonymising** (also `POST /api/users/:id/anonymize`) keeps payments, registrations, tickets and check-ins, and removes: the account's name/email/phone/password, the details of attendees on their registrations and their own attendee rows elsewhere, parental-consent details, form answers and drafts, feedback authorship and invites, waiting waitlist places, pending transfer requests, names on transfers and certificates, sponsor names/messages, login history, password resets and 2FA challenges, and payer fields in provider webhook payloads and checkouts.
|
||||
- Sign-up (`POST /api/users`) takes `newsOptIn: bool`. Login and `GET /api/users/profile` return `newsPromptPending` (never answered the opt-in question).
|
||||
|
||||
## Coupons
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
@@ -579,6 +630,10 @@ Options and variants also accept an optional `staffPrice`, charged automatically
|
||||
| POST | `/api/payments/checkout/send` | supervisor+ | Send a generated payment link by email or WhatsApp (also `/api/payments/yoco-checkout/send`) |
|
||||
| GET | `/api/payments/providers` | public | Providers people can pay with now (enabled and set up), in order: `{ providers: [{ id, label }], minimumAmount: 15 }` |
|
||||
| GET | `/api/payments/providers/settings` | admin | Every provider with `enabled`, `configured` and `capabilities` (`refund`, `getTransaction`, `listTransactions`) |
|
||||
| GET | `/api/payments/pay/:id` | public (checkout id) | The payer's page for a checkout: `{ status: open\|paid, amount, description, provider, providers, cancelUrl, redirectUrl, form }`. `form` (`{ action, fields }`) is set once a form provider (PayFast) is chosen — the page posts it |
|
||||
| POST | `/api/payments/pay/:id` | public (checkout id) | `{ provider }` → starts the checkout with that provider: `{ provider, redirectUrl }` (Yoco) or `{ provider, form }` (PayFast). 409 when already paid |
|
||||
| GET | `/api/payments/checkouts/open` | supervisor+ | Online checkouts from the last 14 days with no payment yet: `{ checkouts: [{ id, provider, providerLabel, canCheck, amount, description, type, registrationId, payer, createdAt }], days }` |
|
||||
| POST | `/api/payments/checkouts/check` | supervisor+ | `{ ids? }` (default: every open one) → asks each provider and records payments it received: `{ results: { [checkoutId]: { status: paid\|recorded\|needs_review\|not_paid\|cant_check, note? } }, recorded, message }`. PayFast: transaction history (API); Yoco: `cant_check` |
|
||||
| PUT | `/api/payments/providers/settings` | admin | `{ providers: [{ id, enabled }] }` in display order. Audit logged (`payment_providers_updated`) |
|
||||
| GET | `/api/payments/mypayments` | user+ | Own payments |
|
||||
| GET | `/api/payments/:id` | user+ | Payment by ID |
|
||||
@@ -652,6 +707,8 @@ Returns: `{ redirectUrl }` or `{ priceUpdated: true, newTotal, message }` if ear
|
||||
|
||||
Dynamic placeholders: `{{name}}`, `{{event.title}}`, `{{event.start}}`, `{{event.link}}`, `{{balance}}`, `{{promo.title}}`, `{{promo.link}}`
|
||||
|
||||
**Message kind (POPIA).** Every bulk send — these broadcasts, WhatsApp broadcasts, and `POST /api/events/:id/email-attendees` / `whatsapp-attendees` (and their `/schedule`) — takes `kind`: `"update"` or `"news"`. Broadcasts default to `news`, attendee messages to `update`; `promoEventId` (the next-event promo automation) forces `news`, `template: "tickets"` forces `update`. `news` goes only to accounts opted in on that channel (`User.newsEmail` / `newsWhatsApp`) and skips addresses in `MessagingOptOut`; it gets an unsubscribe link (email: plus `List-Unsubscribe` / `List-Unsubscribe-Post` headers; WhatsApp: "Reply STOP"). `update` emails get a footer saying why they were sent. Responses (and dry runs) include `kind` and `skipped`. See [Privacy](#privacy-popia).
|
||||
|
||||
---
|
||||
|
||||
## WhatsApp Broadcasts
|
||||
@@ -767,7 +824,7 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/whatsapp/config` | admin | Get current WAWP credentials |
|
||||
| GET | `/api/whatsapp/config` | admin | Get current WAWP credentials, and `webhookUrl` (with its secret) to paste into WAWP |
|
||||
| POST | `/api/whatsapp/config` | admin | Save WAWP credentials to DB |
|
||||
| GET | `/api/whatsapp/status` | admin | WAWP session status |
|
||||
| GET | `/api/whatsapp/qr` | admin | QR code for WhatsApp device linking |
|
||||
@@ -780,12 +837,134 @@ 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 |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/webhooks/payfast` | PayFast ITN | Form-encoded. Checked as PayFast requires: signature (every posted field + passphrase), sender is a PayFast host (`PAYFAST_SKIP_IP_CHECK=true` turns this off for tunnels; behind a proxy set `TRUST_PROXY`), PayFast answers VALID at `/eng/query/validate`, and `amount_gross` equals the checkout (otherwise held for review with a `note`). `COMPLETE` records the payment (`provider: "payfast"`) |
|
||||
| POST | `/api/webhooks/yoco` | HMAC-verified | Yoco payment webhook — verifies the `webhook-signature` header (src/payments/providers/yoco.js), creates `Payment` (`provider: "yoco"`) + `ProviderTransaction`, updates registration status, generates tickets (src/payments/webhook.js). Processed even when Yoco is switched off, so checkouts already started complete |
|
||||
| POST | `/api/webhooks/whatsapp` | — | WAWP inbound event webhook |
|
||||
| POST | `/api/whatsapp/webhook?secret=…` | secret in URL | WAWP events (the URL to paste into WAWP is on `GET /api/whatsapp/config` as `webhookUrl`). `session.status` FAILED → automatic session recovery (works without the secret). `message` → a STOP/unsubscribe reply turns off WhatsApp news for that number and sends a confirmation; only with a matching secret, since WAWP doesn't sign webhooks. Senders identified only by a WhatsApp LID (no phone number) are ignored |
|
||||
| POST | `/api/webhooks/whatsapp` | — | Old placeholder, does nothing — use `/api/whatsapp/webhook` |
|
||||
|
||||
---
|
||||
|
||||
@@ -805,7 +984,11 @@ For what each individual setting key defaults to when it's never been saved, see
|
||||
|
||||
## Yoco
|
||||
|
||||
Managed from Admin → Site Settings → Payments. `YOCO_SECRET_KEY` / `YOCO_WEBHOOK_SECRET` in `.env` are fallbacks used only when the database has no value.
|
||||
Managed from Admin → Site Settings → Payments.
|
||||
|
||||
PayFast: `GET /api/payfast/config` (admin) → `{ merchantId, merchantKey, passphraseMasked, hasPassphrase, mode, notifyUrl, notifyUrlProblem, configured }`; `PUT /api/payfast/config` (admin) `{ merchantId?, merchantKey?, passphrase?, mode? }` ('' clears; the passphrase is stored encrypted; audit logged as `payfast_config_updated`); `POST /api/payfast/test` (admin) checks the saved settings with a signed PayFast API ping.
|
||||
|
||||
Every checkout is stored as a `ProviderCheckout` (amount, metadata, request): `POST /api/payments/checkout` returns `checkoutId` (ours) and a `redirectUrl` — the provider's page, or `/pay/<checkoutId>` when the payer must choose or a form has to be posted. Registrations keep that `checkoutId`; webhooks find the checkout by our id or the provider's. `YOCO_SECRET_KEY` / `YOCO_WEBHOOK_SECRET` in `.env` are fallbacks used only when the database has no value.
|
||||
|
||||
| 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.36.0",
|
||||
"version": "1.46.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.36.0",
|
||||
"version": "1.46.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.36.0",
|
||||
"version": "1.46.1",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'payfast_config_updated';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ProviderTransaction" ADD COLUMN "note" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProviderCheckout" (
|
||||
"id" TEXT NOT NULL,
|
||||
"provider" TEXT,
|
||||
"externalId" TEXT,
|
||||
"redirectUrl" TEXT,
|
||||
"amountCents" INTEGER NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'ZAR',
|
||||
"metadata" JSONB NOT NULL,
|
||||
"request" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ProviderCheckout_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProviderCheckout_provider_externalId_idx" ON "ProviderCheckout"("provider", "externalId");
|
||||
@@ -0,0 +1,102 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DataRequestType" AS ENUM ('access', 'correction', 'deletion');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DataRequestStatus" AS ENUM ('open', 'completed', 'rejected', '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 'data_request_completed';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'data_request_rejected';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'user_anonymised';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "newsAskedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "newsEmail" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "newsWhatsApp" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CommunicationConsent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"address" TEXT,
|
||||
"channel" TEXT NOT NULL,
|
||||
"granted" BOOLEAN NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"ip" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "CommunicationConsent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MessagingOptOut" (
|
||||
"id" TEXT NOT NULL,
|
||||
"channel" TEXT NOT NULL,
|
||||
"address" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "MessagingOptOut_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DataRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" "DataRequestType" NOT NULL,
|
||||
"status" "DataRequestStatus" NOT NULL DEFAULT 'open',
|
||||
"userId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"details" TEXT,
|
||||
"response" TEXT,
|
||||
"selfService" BOOLEAN NOT NULL DEFAULT false,
|
||||
"dueAt" TIMESTAMP(3) NOT NULL,
|
||||
"closedAt" TIMESTAMP(3),
|
||||
"handledById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "DataRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CommunicationConsent_userId_createdAt_idx" ON "CommunicationConsent"("userId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CommunicationConsent_address_idx" ON "CommunicationConsent"("address");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MessagingOptOut_channel_address_key" ON "MessagingOptOut"("channel", "address");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DataRequest_status_dueAt_idx" ON "DataRequest"("status", "dueAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DataRequest_userId_idx" ON "DataRequest"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CommunicationConsent" ADD CONSTRAINT "CommunicationConsent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DataRequest" ADD CONSTRAINT "DataRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DataRequest" ADD CONSTRAINT "DataRequest_handledById_fkey" FOREIGN KEY ("handledById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Existing accounts: kept opted in to news (WhatsApp only when they have a phone number),
|
||||
-- logged as "existing_account", and asked once at their next login (newsAskedAt stays null).
|
||||
UPDATE "User" SET "newsEmail" = true, "newsWhatsApp" = ("phoneNumber" IS NOT NULL)
|
||||
WHERE "isActive" = true AND "email" NOT LIKE '%@deleted.invalid' AND "email" NOT LIKE '%@deleted.local' AND "email" NOT LIKE '%@guest.local';
|
||||
|
||||
INSERT INTO "CommunicationConsent" ("id", "userId", "channel", "granted", "source")
|
||||
SELECT gen_random_uuid()::text, "id", 'email', true, 'existing_account' FROM "User" WHERE "newsEmail" = true;
|
||||
|
||||
INSERT INTO "CommunicationConsent" ("id", "userId", "channel", "granted", "source")
|
||||
SELECT gen_random_uuid()::text, "id", 'whatsapp', true, 'existing_account' FROM "User" WHERE "newsWhatsApp" = true;
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
-- 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 'privacy_notice_published';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'operator_created';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'operator_updated';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'operator_deleted';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "noticeVersionAccepted" INTEGER;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PrivacyNotice" (
|
||||
"id" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL,
|
||||
"sections" JSONB NOT NULL,
|
||||
"changeSummary" TEXT,
|
||||
"requiresAcceptance" BOOLEAN NOT NULL DEFAULT false,
|
||||
"publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"publishedById" TEXT,
|
||||
|
||||
CONSTRAINT "PrivacyNotice_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "NoticeAcceptance" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"version" INTEGER NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"ip" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "NoticeAcceptance_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Operator" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"purpose" TEXT NOT NULL,
|
||||
"dataShared" TEXT NOT NULL,
|
||||
"location" TEXT,
|
||||
"website" TEXT,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Operator_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DataAccessLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"actorId" TEXT,
|
||||
"actorRole" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"what" TEXT NOT NULL,
|
||||
"eventId" TEXT,
|
||||
"records" INTEGER,
|
||||
"ip" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "DataAccessLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PrivacyNotice_version_key" ON "PrivacyNotice"("version");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NoticeAcceptance_userId_createdAt_idx" ON "NoticeAcceptance"("userId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NoticeAcceptance_version_idx" ON "NoticeAcceptance"("version");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DataAccessLog_createdAt_idx" ON "DataAccessLog"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DataAccessLog_actorId_createdAt_idx" ON "DataAccessLog"("actorId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DataAccessLog_action_createdAt_idx" ON "DataAccessLog"("action", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PrivacyNotice" ADD CONSTRAINT "PrivacyNotice_publishedById_fkey" FOREIGN KEY ("publishedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NoticeAcceptance" ADD CONSTRAINT "NoticeAcceptance_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DataAccessLog" ADD CONSTRAINT "DataAccessLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- The operator register starts with the services Hope Events itself uses. Admins correct the
|
||||
-- details (e.g. which email and hosting provider) in Admin → Privacy → Operators.
|
||||
INSERT INTO "Operator" ("id", "name", "purpose", "dataShared", "location", "website", "active", "order", "updatedAt") VALUES
|
||||
(gen_random_uuid()::text, 'Yoco', 'Card payments', 'Payment amount and reference; card details are entered on Yoco''s own page', 'South Africa', 'https://www.yoco.com', true, 1, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid()::text, 'PayFast', 'Online payments', 'Name, email address, payment amount and reference', 'South Africa', 'https://payfast.io', true, 2, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid()::text, 'WAWP', 'Delivering WhatsApp messages', 'Mobile number and message content', NULL, 'https://wawp.net', true, 3, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid()::text, 'WhatsApp (Meta Platforms)', 'The WhatsApp messaging service', 'Mobile number and message content', NULL, 'https://www.whatsapp.com/legal/privacy-policy', true, 4, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid()::text, 'Email delivery provider', 'Sending emails (the SMTP server in Site settings)', 'Name, email address and message content', NULL, NULL, true, 5, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid()::text, 'Hosting provider', 'The servers this site and its database run on', 'All information held by the site', 'South Africa', NULL, true, 6, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid()::text, 'Google Maps', 'Maps on event pages', 'Your device''s IP address and browser details when a map loads', NULL, 'https://policies.google.com/privacy', true, 7, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid()::text, 'Sentry', 'Error monitoring (only when it is set up)', 'Technical error reports, which can include the signed-in user''s id', NULL, 'https://sentry.io', false, 8, CURRENT_TIMESTAMP);
|
||||
@@ -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;
|
||||
|
||||
@@ -75,6 +75,19 @@ model User {
|
||||
notificationPreference NotificationPreference @default(email)
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorMethod TwoFactorMethod?
|
||||
// POPIA: opted in to "News & announcements" (marketing) bulk messages, per channel.
|
||||
// Event updates to registrants don't depend on these. History in CommunicationConsent.
|
||||
newsEmail Boolean @default(false)
|
||||
newsWhatsApp Boolean @default(false)
|
||||
// When the person last answered the news opt-in question. Null = ask at next login.
|
||||
newsAskedAt DateTime?
|
||||
// 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")
|
||||
@@ -121,6 +134,67 @@ model User {
|
||||
trustedDevices TrustedDevice[]
|
||||
certificates Certificate[]
|
||||
certificatesIssued Certificate[] @relation("CertificateIssuedBy")
|
||||
communicationConsents CommunicationConsent[]
|
||||
dataRequests DataRequest[]
|
||||
dataRequestsHandled DataRequest[] @relation("DataRequestHandledBy")
|
||||
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 {
|
||||
@@ -167,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?
|
||||
@@ -223,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 {
|
||||
@@ -317,6 +414,7 @@ model Registration {
|
||||
bundleApplications RegistrationBundle[]
|
||||
attendees Attendee[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
volunteerSlots VolunteerSlot[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([eventId])
|
||||
@@ -564,12 +662,43 @@ enum AdminAuditAction {
|
||||
yoco_webhook_registered
|
||||
yoco_webhook_deleted
|
||||
payment_providers_updated
|
||||
payfast_config_updated
|
||||
event_sessions_changed
|
||||
attendance_marked
|
||||
attendance_unmarked
|
||||
certificate_issued
|
||||
certificate_revoked
|
||||
job_run_manual
|
||||
data_request_completed
|
||||
data_request_rejected
|
||||
user_anonymised
|
||||
privacy_notice_published
|
||||
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
|
||||
@@ -700,6 +829,9 @@ model ProviderTransaction {
|
||||
paymentId String?
|
||||
payment Payment? @relation(fields: [paymentId], references: [id], onDelete: SetNull)
|
||||
raw Json?
|
||||
// Why it wasn't recorded automatically and needs a look (e.g. paid a different amount
|
||||
// from the checkout).
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -707,6 +839,27 @@ model ProviderTransaction {
|
||||
@@index([paymentId])
|
||||
}
|
||||
|
||||
// An online checkout we started: what it's for and how much, so the payment that comes back
|
||||
// can be checked against it. Its id is the payer's link (/pay/<id>) and, for PayFast, the
|
||||
// m_payment_id. `provider` is null until the payer picks one (when several are enabled).
|
||||
model ProviderCheckout {
|
||||
id String @id @default(uuid())
|
||||
provider String?
|
||||
// The provider's own checkout id (Yoco) and where it sends the payer.
|
||||
externalId String?
|
||||
redirectUrl String?
|
||||
amountCents Int
|
||||
currency String @default("ZAR")
|
||||
// Who/what the payment is for: registrationId, userId, eventId, type, donationAmount, …
|
||||
metadata Json
|
||||
// Everything needed to start the checkout with any provider: lineItems, return URLs, payer.
|
||||
request Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([provider, externalId])
|
||||
}
|
||||
|
||||
model Section {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
@@ -1219,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])
|
||||
@@ -1499,6 +1661,7 @@ model EventSession {
|
||||
reminders SessionReminderLog[]
|
||||
registrationOptions RegistrationOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
servingRoles ServingRole[]
|
||||
|
||||
@@index([eventId, startsAt])
|
||||
}
|
||||
@@ -1542,3 +1705,429 @@ model Certificate {
|
||||
@@index([ticketId])
|
||||
@@index([issuedById])
|
||||
}
|
||||
|
||||
// ─── POPIA ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Every change to someone's "News & announcements" opt-in: who, which channel, on or off,
|
||||
// when and how. Append-only — it's the proof of consent. `address` is set instead of
|
||||
// userId for people without an account (an ad-hoc broadcast address that unsubscribed).
|
||||
model CommunicationConsent {
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
address String?
|
||||
channel String // email | whatsapp
|
||||
granted Boolean
|
||||
// signup | privacy_centre | prompt | unsubscribe_link | whatsapp_stop | existing_account | admin
|
||||
source String
|
||||
ip String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([address])
|
||||
}
|
||||
|
||||
// Addresses (email, or phone as 27…) that unsubscribed from news without an account,
|
||||
// e.g. ad-hoc broadcast recipients. News broadcasts skip them.
|
||||
model MessagingOptOut {
|
||||
id String @id @default(uuid())
|
||||
channel String // email | whatsapp
|
||||
address String
|
||||
source String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([channel, address])
|
||||
}
|
||||
|
||||
enum DataRequestType {
|
||||
access
|
||||
correction
|
||||
deletion
|
||||
}
|
||||
|
||||
enum DataRequestStatus {
|
||||
open
|
||||
completed
|
||||
rejected
|
||||
cancelled
|
||||
}
|
||||
|
||||
// A POPIA request from a data subject. Name and email are copied in so the request
|
||||
// still shows who made it after the account is anonymised.
|
||||
model DataRequest {
|
||||
id String @id @default(uuid())
|
||||
type DataRequestType
|
||||
status DataRequestStatus @default(open)
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
name String
|
||||
email String
|
||||
details String?
|
||||
// The admin's answer, sent to the requester when the request is closed.
|
||||
response String?
|
||||
// Made and fulfilled by the person themselves (e.g. "Download my data").
|
||||
selfService Boolean @default(false)
|
||||
dueAt DateTime
|
||||
closedAt DateTime?
|
||||
handledById String?
|
||||
handledBy User? @relation("DataRequestHandledBy", fields: [handledById], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status, dueAt])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// A published version of the privacy notice (versions start at 1). With none published,
|
||||
// the built-in notice from utils/privacyNotice.js is shown as version 0. Section bodies
|
||||
// keep their {placeholders} (org name, Information Officer, operators…), filled in when
|
||||
// shown, so the notice follows Site settings and the operator register.
|
||||
model PrivacyNotice {
|
||||
id String @id @default(uuid())
|
||||
version Int @unique
|
||||
sections Json // [{ id, title, body }]
|
||||
changeSummary String?
|
||||
// Everyone is asked to accept this version again after logging in.
|
||||
requiresAcceptance Boolean @default(false)
|
||||
publishedAt DateTime @default(now())
|
||||
publishedById String?
|
||||
publishedBy User? @relation(fields: [publishedById], references: [id], onDelete: SetNull)
|
||||
}
|
||||
|
||||
// Someone accepted a version of the privacy notice. Append-only proof, like
|
||||
// CommunicationConsent.
|
||||
model NoticeAcceptance {
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
version Int
|
||||
source String // signup | prompt
|
||||
ip String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([version])
|
||||
}
|
||||
|
||||
// A third party that processes personal information for the church (POPIA "operator"):
|
||||
// payment providers, WhatsApp and email delivery, hosting… Active ones are listed in the
|
||||
// privacy notice.
|
||||
model Operator {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
purpose String
|
||||
dataShared String
|
||||
location String?
|
||||
website String?
|
||||
active Boolean @default(true)
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// Someone exported or viewed personal information in bulk: report exports, the attendance
|
||||
// register, certificate downloads, group lists, form answers, the consent and medical lists,
|
||||
// backups, and viewing the site as another user.
|
||||
model DataAccessLog {
|
||||
id String @id @default(uuid())
|
||||
actorId String?
|
||||
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
|
||||
actorRole String
|
||||
// report_pdf | report_excel | report_email | report_whatsapp | attendance_export |
|
||||
// certificates_download | group_lists | form_responses | consent_list | medical_list |
|
||||
// backup_download | impersonation
|
||||
action String
|
||||
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())
|
||||
|
||||
@@index([createdAt])
|
||||
@@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);
|
||||
@@ -287,6 +300,10 @@ const getSetLists = async (req, res) => {
|
||||
if (!set) throw allocationError('Allocation set not found', 404);
|
||||
const withMedical = req.query.medical === 'true';
|
||||
const members = await groupMembers(set.groups.map(g => g.id), { withMedical });
|
||||
require('../utils/accessLog').logDataAccess(req, {
|
||||
action: withMedical ? 'medical_list' : 'group_lists', what: `${set.name} lists — ${set.event?.title || 'event'}${withMedical ? ' (with medical details)' : ''}`,
|
||||
eventId: set.eventId, records: members.length,
|
||||
});
|
||||
if (withMedical) {
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'medical_list_viewed', targetType: 'AllocationSet', targetId: set.id, metadata: { eventId: set.eventId, attendees: members.length }, ip: getClientIp(req) });
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -175,6 +175,10 @@ const exportRegister = async (req, res) => {
|
||||
try {
|
||||
const reg = await buildRegister(req.params.eventId);
|
||||
const cols = reg.columns.filter(c => !c.cancelled);
|
||||
require('../utils/accessLog').logDataAccess(req, {
|
||||
action: 'attendance_export', what: `Attendance register — ${reg.event.title} (${req.query.format === 'pdf' ? 'PDF' : 'Excel'})`,
|
||||
eventId: req.params.eventId, records: reg.rows.length,
|
||||
});
|
||||
if (req.query.format === 'pdf') {
|
||||
const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 36 });
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
@@ -320,6 +324,7 @@ const downloadEventCertificates = async (req, res) => {
|
||||
const certs = await prisma.certificate.findMany({ where: { eventId: event.id, revokedAt: null }, orderBy: { name: 'asc' } });
|
||||
if (certs.length === 0) throw badRequest('No certificates have been issued for this event yet.');
|
||||
const pdf = await certificates.renderCertificatesPdf(certs, event);
|
||||
require('../utils/accessLog').logDataAccess(req, { action: 'certificates_download', what: `Certificates — ${event.title}`, eventId: event.id, records: certs.length });
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${`Certificates - ${event.title}`.replace(/[^a-z0-9 -]/gi, '').slice(0, 80)}.pdf"`);
|
||||
res.send(pdf);
|
||||
|
||||
@@ -44,6 +44,7 @@ const downloadBackup = async (req, res) => {
|
||||
res.status(404);
|
||||
throw new Error('Backup not found');
|
||||
}
|
||||
require('../utils/accessLog').logDataAccess(req, { action: 'backup_download', what: `Database backup ${filename}` });
|
||||
res.download(filePath, filename);
|
||||
} catch (e) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
|
||||
|
||||
@@ -30,6 +30,7 @@ function buildEventContext(event) {
|
||||
}
|
||||
|
||||
const { replacePlaceholders } = require('../utils/placeholders');
|
||||
const { normaliseKind, filterRecipients, bulkEmailFooter } = require('../utils/privacy');
|
||||
|
||||
function parseFreeformEmails(lines) {
|
||||
// Supports formats:
|
||||
@@ -103,16 +104,16 @@ const previewBroadcast = async (req, res) => {
|
||||
if (!email) continue;
|
||||
if (!map.has(email)) map.set(email, { email, name: r.name || '' });
|
||||
}
|
||||
const recipients = Array.from(map.values());
|
||||
const kind = normaliseKind(req.body?.kind) || 'news';
|
||||
const { recipients, skipped } = await filterRecipients(Array.from(map.values()), { channel: 'email', kind });
|
||||
|
||||
// Optionally resolve event info for link/title placeholder
|
||||
let event = null;
|
||||
if (eventId && typeof eventId === 'string') {
|
||||
event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
}
|
||||
const eventCtx = buildEventContext(event);
|
||||
|
||||
return res.json({ matched: recipients.length, recipients: recipients.slice(0, 20), event: event ? { id: event.id, title: event.title } : null, placeholders: ['{{name}}','{{event.title}}','{{event.start}}','{{event.link}}'] });
|
||||
return res.json({ kind, matched: recipients.length, skipped, recipients: recipients.slice(0, 20), event: event ? { id: event.id, title: event.title } : null, placeholders: ['{{name}}','{{event.title}}','{{event.start}}','{{event.link}}'] });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -147,9 +148,12 @@ const sendBroadcast = async (req, res) => {
|
||||
if (!email) continue;
|
||||
if (!map.has(email)) map.set(email, { email, name: r.name || '' });
|
||||
}
|
||||
const recipients = Array.from(map.values());
|
||||
// Ad-hoc broadcasts are News & announcements unless the sender marks them as an event
|
||||
// update; news skips people who haven't opted in or unsubscribed.
|
||||
const kind = normaliseKind(req.body?.kind) || 'news';
|
||||
const { recipients, skipped } = await filterRecipients(Array.from(map.values()), { channel: 'email', kind });
|
||||
if (recipients.length === 0) {
|
||||
return res.status(400).json({ message: 'No valid recipients' });
|
||||
return res.status(400).json({ message: skipped ? 'Everyone selected has unsubscribed from news.' : 'No valid recipients' });
|
||||
}
|
||||
|
||||
// Resolve event
|
||||
@@ -177,9 +181,10 @@ const sendBroadcast = async (req, res) => {
|
||||
eventLinkHtml: eventCtx.eventLinkHtml,
|
||||
} : ctxBase;
|
||||
const finalSubject = await replacePlaceholders(subject, ctxBase);
|
||||
const finalHtml = html ? await replacePlaceholders(html, ctxForHtml) : undefined;
|
||||
const finalText = (!html ? await replacePlaceholders(text || '', ctxBase) : undefined);
|
||||
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
|
||||
const footer = bulkEmailFooter({ kind, recipient: rcpt, eventTitle: eventCtx.eventTitle });
|
||||
const finalHtml = html ? (await replacePlaceholders(html, ctxForHtml)) + footer.html : undefined;
|
||||
const finalText = (!html ? (await replacePlaceholders(text || '', ctxBase)) + footer.text : undefined);
|
||||
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText, headers: footer.headers });
|
||||
}));
|
||||
|
||||
const sent = results.filter(r => r.status === 'fulfilled').length;
|
||||
@@ -189,7 +194,7 @@ const sendBroadcast = async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({ matched: recipients.length, sent });
|
||||
return res.json({ kind, matched: recipients.length, skipped, sent });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -200,13 +205,13 @@ const sendBroadcast = async (req, res) => {
|
||||
// @access Private/Supervisor or Admin
|
||||
const scheduleBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { scheduledAt, subject, html, text, userIds, emails, eventId } = req.body || {};
|
||||
const { scheduledAt, subject, html, text, userIds, emails, eventId, kind } = req.body || {};
|
||||
if (!scheduledAt) return res.status(400).json({ message: 'scheduledAt is required' });
|
||||
const when = new Date(scheduledAt);
|
||||
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
|
||||
if (!subject || !(html || text)) return res.status(400).json({ message: 'Subject and message (html or text) are required' });
|
||||
|
||||
const payload = { subject, html, text, userIds, emails, eventId };
|
||||
const payload = { subject, html, text, userIds, emails, eventId, kind };
|
||||
|
||||
const recipientSummary = await describeBroadcastRecipients({ userIds, emails: parseFreeformEmails(emails) });
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -156,6 +156,7 @@ const listEventConsent = async (req, res) => {
|
||||
const { event, rows } = await consentRows(req.params.eventId);
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
const counts = rows.reduce((c, r) => { c[r.status] = (c[r.status] || 0) + 1; return c; }, {});
|
||||
require('../utils/accessLog').logDataAccess(req, { action: 'consent_list', what: `Parental consent — ${event.title}`, eventId: event.id, records: rows.length });
|
||||
res.json({
|
||||
event: { id: event.id, title: event.title, startDate: event.startDate, requiresParentalConsent: event.requiresParentalConsent, consentTextVersion: event.consentTextVersion },
|
||||
counts,
|
||||
@@ -255,6 +256,7 @@ const medicalList = async (req, res) => {
|
||||
targetType: 'Event', targetId: event.id, metadata: { attendees: list.length },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
require('../utils/accessLog').logDataAccess(req, { action: 'medical_list', what: `Medical list — ${event.title}`, eventId: event.id, records: list.length });
|
||||
res.json({ event: { id: event.id, title: event.title, startDate: event.startDate }, rows: list });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
|
||||
@@ -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',
|
||||
@@ -1586,8 +1637,16 @@ const emailEventAttendees = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// POPIA: an "update" (about this event) goes to everyone selected; "news" (the next-event
|
||||
// promo, or when the sender says so) only to people who opted in. Tickets are always sent.
|
||||
const { normaliseKind, filterRecipients, bulkEmailFooter } = require('../utils/privacy');
|
||||
const kind = template === 'tickets' ? 'update' : (promoEvent ? 'news' : (normaliseKind(req.body?.kind) || 'update'));
|
||||
const allowed = await filterRecipients(recipients, { channel: 'email', kind });
|
||||
recipients.length = 0;
|
||||
recipients.push(...allowed.recipients);
|
||||
|
||||
if (dryRun) {
|
||||
return res.json({ eventId, matched: recipients.length, recipients: recipients.slice(0, 20) });
|
||||
return res.json({ eventId, kind, matched: recipients.length, skipped: allowed.skipped, recipients: recipients.slice(0, 20) });
|
||||
}
|
||||
|
||||
const { sendMail } = require('../utils/email');
|
||||
@@ -1730,10 +1789,11 @@ const emailEventAttendees = async (req, res) => {
|
||||
|
||||
// Always perform placeholder replacement on whatever we have
|
||||
const finalSubject = await replacePlaceholders(subj || '', ctxBase);
|
||||
const finalHtml = h ? await replacePlaceholders(h, ctxHtml) : undefined;
|
||||
const finalText = (!h ? await replacePlaceholders(t || '', ctxBase) : undefined);
|
||||
const footer = bulkEmailFooter({ kind, recipient: rcpt, eventTitle: event.title });
|
||||
const finalHtml = h ? (await replacePlaceholders(h, ctxHtml)) + footer.html : undefined;
|
||||
const finalText = (!h ? (await replacePlaceholders(t || '', ctxBase)) + footer.text : undefined);
|
||||
|
||||
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
|
||||
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText, headers: footer.headers });
|
||||
}));
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
@@ -1824,10 +1884,15 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
uniqMap.set(phone, { phone, name: r.user?.name || '', userId: r.user?.id, registrationId: r.id });
|
||||
}
|
||||
}
|
||||
const recipients = Array.from(uniqMap.values());
|
||||
// POPIA: see emailEventAttendees — news only to people who opted in to WhatsApp news.
|
||||
const { normaliseKind, filterRecipients, whatsAppNewsFooter } = require('../utils/privacy');
|
||||
const promoEventId = req.body?.promoEventId;
|
||||
const kind = template === 'tickets' ? 'update' : (promoEventId ? 'news' : (normaliseKind(req.body?.kind) || 'update'));
|
||||
const allowed = await filterRecipients(Array.from(uniqMap.values()), { channel: 'whatsapp', kind });
|
||||
const recipients = allowed.recipients;
|
||||
|
||||
if (dryRun) {
|
||||
return res.json({ eventId, matched: recipients.length, recipients: recipients.slice(0, 20) });
|
||||
return res.json({ eventId, kind, matched: recipients.length, skipped: allowed.skipped, recipients: recipients.slice(0, 20) });
|
||||
}
|
||||
|
||||
const { sendText } = require('../utils/whatsapp');
|
||||
@@ -1927,7 +1992,7 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
}
|
||||
|
||||
const finalMessage = await replacePlaceholders(msg || '', ctx);
|
||||
await sendText(rcpt.phone, finalMessage);
|
||||
await sendText(rcpt.phone, kind === 'news' ? finalMessage + whatsAppNewsFooter(rcpt) : finalMessage);
|
||||
}));
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
@@ -1955,7 +2020,7 @@ function describeAttendeeFilter(filter) {
|
||||
const scheduleEmailEventAttendees = async (req, res) => {
|
||||
try {
|
||||
const eventId = req.params.id;
|
||||
const { scheduledAt, subject, html, text, filter, template } = req.body || {};
|
||||
const { scheduledAt, subject, html, text, filter, template, kind } = req.body || {};
|
||||
|
||||
// Validate event
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
@@ -1973,7 +2038,7 @@ const scheduleEmailEventAttendees = async (req, res) => {
|
||||
}
|
||||
|
||||
// Build payload matching emailEventAttendees body
|
||||
const payload = { subject, html, text, filter: filter || {}, template };
|
||||
const payload = { subject, html, text, filter: filter || {}, template, kind };
|
||||
|
||||
const { addJob } = require('../utils/scheduledEmails');
|
||||
const created = addJob({
|
||||
@@ -1996,7 +2061,7 @@ const scheduleEmailEventAttendees = async (req, res) => {
|
||||
const scheduleWhatsappEventAttendees = async (req, res) => {
|
||||
try {
|
||||
const eventId = req.params.id;
|
||||
const { scheduledAt, message, filter, template } = req.body || {};
|
||||
const { scheduledAt, message, filter, template, kind } = req.body || {};
|
||||
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
@@ -2010,7 +2075,7 @@ const scheduleWhatsappEventAttendees = async (req, res) => {
|
||||
throw new Error('message is required unless template is "tickets"');
|
||||
}
|
||||
|
||||
const payload = { message, filter: filter || {}, template };
|
||||
const payload = { message, filter: filter || {}, template, kind };
|
||||
|
||||
const { addJob } = require('../utils/scheduledEmails');
|
||||
const created = addJob({
|
||||
|
||||
@@ -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 };
|
||||
@@ -43,6 +44,17 @@ const listFormResponses = async (req, res) => {
|
||||
|
||||
const nextCursor = responses.length === take ? responses[responses.length - 1].id : null;
|
||||
|
||||
// The first page of a list (not one registration's own form, nor the next pages of the
|
||||
// same list) is logged as a bulk view of form answers.
|
||||
if (!registrationId && !cursor && responses.length) {
|
||||
require('../utils/accessLog').logDataAccess(req, {
|
||||
action: 'form_responses',
|
||||
what: `Attendee form answers${responses[0].registration?.event && eventId ? ` — ${responses[0].registration.event.title}` : ''}`,
|
||||
eventId: eventId ? String(eventId) : null,
|
||||
records: responses.length,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ items: responses, nextCursor });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
|
||||
@@ -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 };
|
||||
@@ -30,6 +30,7 @@ const startImpersonation = async (req, res) => {
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'impersonation_started', targetType: 'User', targetId: target.id,
|
||||
metadata: { sessionId: session.id, reason: session.reason, targetName: target.name, expiresAt }, ip: getClientIp(req),
|
||||
});
|
||||
require('../utils/accessLog').logDataAccess(req, { action: 'impersonation', what: `Viewed as ${target.name} — ${session.reason}` });
|
||||
|
||||
// Optional: tell the user (Site Settings → Security).
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { encrypt } = require('../utils/encryption');
|
||||
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { maskSecret } = require('../utils/yoco');
|
||||
const {
|
||||
KEY_MERCHANT_ID, KEY_MERCHANT_KEY, KEY_PASSPHRASE, KEY_MODE,
|
||||
getPayfastConfig, payfastNotifyUrl, notifyUrlProblem,
|
||||
} = require('../utils/payfast');
|
||||
|
||||
const fail = (res, e) => res.status(e.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(e) });
|
||||
|
||||
// @desc PayFast settings (passphrase masked) and the ITN notify URL
|
||||
// @route GET /api/payfast/config
|
||||
// @access Admin
|
||||
const getConfig = async (req, res) => {
|
||||
try {
|
||||
const c = await getPayfastConfig();
|
||||
const notifyUrl = payfastNotifyUrl();
|
||||
res.json({
|
||||
merchantId: c.merchantId,
|
||||
merchantKey: c.merchantKey,
|
||||
passphraseMasked: c.passphrase ? maskSecret(c.passphrase) : '',
|
||||
hasPassphrase: !!c.passphrase,
|
||||
mode: c.mode,
|
||||
notifyUrl,
|
||||
notifyUrlProblem: notifyUrlProblem(notifyUrl),
|
||||
configured: !!(c.merchantId && c.merchantKey),
|
||||
});
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Save PayFast settings ('' clears the passphrase; omitted fields stay as they are)
|
||||
// @route PUT /api/payfast/config { merchantId?, merchantKey?, passphrase?, mode? }
|
||||
// @access Admin
|
||||
const updateConfig = async (req, res) => {
|
||||
try {
|
||||
const { merchantId, merchantKey, passphrase, mode } = req.body || {};
|
||||
const updates = {};
|
||||
if (typeof merchantId === 'string') {
|
||||
const v = merchantId.trim();
|
||||
if (v && !/^\d{5,12}$/.test(v)) { res.status(400); throw new Error('The merchant ID is a number, e.g. 10000100.'); }
|
||||
updates[KEY_MERCHANT_ID] = v;
|
||||
}
|
||||
if (typeof merchantKey === 'string') {
|
||||
const v = merchantKey.trim();
|
||||
if (v && !/^[A-Za-z0-9]{8,32}$/.test(v)) { res.status(400); throw new Error('The merchant key is letters and numbers, as shown on your PayFast dashboard.'); }
|
||||
updates[KEY_MERCHANT_KEY] = v;
|
||||
}
|
||||
if (typeof passphrase === 'string') {
|
||||
// PayFast passphrases are 32 characters at most; spaces at the ends are trimmed by PayFast.
|
||||
if (passphrase.trim().length > 32) { res.status(400); throw new Error('The passphrase can be at most 32 characters.'); }
|
||||
updates[KEY_PASSPHRASE] = passphrase.trim();
|
||||
}
|
||||
if (mode !== undefined) {
|
||||
if (!['sandbox', 'live'].includes(mode)) { res.status(400); throw new Error('Mode is sandbox or live.'); }
|
||||
updates[KEY_MODE] = mode;
|
||||
}
|
||||
if (!Object.keys(updates).length) { res.status(400); throw new Error('Nothing to save'); }
|
||||
|
||||
await prisma.$transaction(Object.entries(updates).map(([key, value]) => {
|
||||
const stored = value && key === KEY_PASSPHRASE ? encrypt(value) : value;
|
||||
return prisma.appSetting.upsert({ where: { key }, update: { value: stored }, create: { key, value: stored } });
|
||||
}));
|
||||
invalidateSettingsCache();
|
||||
await warmCache();
|
||||
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'payfast_config_updated', targetType: 'AppSetting',
|
||||
metadata: { changedKeys: Object.keys(updates), ...(updates[KEY_MODE] ? { mode: updates[KEY_MODE] } : {}) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ message: 'PayFast settings saved' });
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Check the saved merchant ID and passphrase with PayFast (a signed API ping)
|
||||
// @route POST /api/payfast/test
|
||||
// @access Admin
|
||||
const testConnection = async (req, res) => {
|
||||
try {
|
||||
const c = await getPayfastConfig();
|
||||
if (!c.merchantId) { res.status(400); throw new Error('Save the merchant ID first.'); }
|
||||
await require('../payments/providers/payfast').testConnection();
|
||||
res.json({ message: `Connected to PayFast (${c.mode === 'live' ? 'live' : 'sandbox'}).` });
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getConfig, updateConfig, testConnection };
|
||||
@@ -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) });
|
||||
@@ -1010,6 +1014,8 @@ const createCheckout = async (req, res) => {
|
||||
amountCents: amountInCents,
|
||||
metadata: { userId, eventId, eventTitle: event.title, type: 'donation', isDonation: true },
|
||||
lineItems: [{ name: `Donation to ${event.title}`, quantity: 1, unitCents: amountInCents }],
|
||||
description: `Donation to ${event.title}`,
|
||||
payer: { name: req.user.name || '', email: req.user.email || '' },
|
||||
successUrl, cancelUrl, failureUrl,
|
||||
idempotencyKey: `don_${eventId}_${amountInCents}_${Date.now()}`,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const prisma = require('../config/db');
|
||||
const payments = require('../payments');
|
||||
const { checkoutPaid, checkCheckouts, openCheckouts } = require('../payments/status');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
@@ -58,4 +59,119 @@ const updatePaymentProviderSettings = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings };
|
||||
/** A checkout and whether it has been paid (a payment came back for it, or its registration is paid). */
|
||||
async function loadCheckout(id) {
|
||||
const checkout = await prisma.providerCheckout.findUnique({ where: { id: String(id || '') } });
|
||||
if (!checkout) return null;
|
||||
return { checkout, paid: await checkoutPaid(checkout) };
|
||||
}
|
||||
|
||||
const providerList = (list) => list.map(p => ({ id: p.id, label: p.label }));
|
||||
|
||||
// @desc A checkout for the payer's page (/pay/:id): amount, what it's for, the providers
|
||||
// they can pay with, and — once a form provider (PayFast) is chosen — its signed form
|
||||
// @route GET /api/payments/pay/:id
|
||||
// @access Public (the checkout id is the credential)
|
||||
const getPayPage = async (req, res) => {
|
||||
try {
|
||||
const found = await loadCheckout(req.params.id);
|
||||
if (!found) { res.status(404); throw new Error('This payment link is not valid.'); }
|
||||
const { checkout, paid } = found;
|
||||
const request = checkout.request || {};
|
||||
const out = {
|
||||
id: checkout.id,
|
||||
status: paid ? 'paid' : 'open',
|
||||
amount: checkout.amountCents / 100,
|
||||
description: request.description || '',
|
||||
provider: checkout.provider,
|
||||
providers: providerList(await payments.availableProviders()),
|
||||
cancelUrl: request.cancelUrl || null,
|
||||
redirectUrl: null,
|
||||
form: null,
|
||||
};
|
||||
if (!paid && checkout.provider) {
|
||||
const provider = payments.getProvider(checkout.provider);
|
||||
if (checkout.redirectUrl) out.redirectUrl = checkout.redirectUrl;
|
||||
else if (provider) out.form = (await payments.startCheckout(checkout, provider)).form || null;
|
||||
}
|
||||
res.json(out);
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Start a checkout with the provider the payer picked
|
||||
// @route POST /api/payments/pay/:id { provider }
|
||||
// @access Public (the checkout id is the credential)
|
||||
const choosePayProvider = async (req, res) => {
|
||||
try {
|
||||
const found = await loadCheckout(req.params.id);
|
||||
if (!found) { res.status(404); throw new Error('This payment link is not valid.'); }
|
||||
if (found.paid) { res.status(409); throw new Error('This has already been paid.'); }
|
||||
const provider = payments.getProvider(req.body?.provider);
|
||||
if (!provider) { res.status(400); throw new Error('Choose how you\'d like to pay.'); }
|
||||
const usable = (await payments.availableProviders()).some(p => p.id === provider.id);
|
||||
if (!usable) { res.status(400); throw new Error(`Paying with ${provider.label} isn't available right now.`); }
|
||||
const started = await payments.startCheckout(found.checkout, provider);
|
||||
res.json({ provider: provider.id, redirectUrl: started.form ? null : started.redirectUrl, form: started.form || null });
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
const OPEN_DAYS = 14;
|
||||
|
||||
/** An open checkout for the Payments page: who/what it's for, and whether its provider can be asked. */
|
||||
async function describeCheckout(c, users) {
|
||||
const meta = c.metadata || {};
|
||||
const provider = payments.getProvider(c.provider);
|
||||
return {
|
||||
id: c.id,
|
||||
provider: c.provider,
|
||||
providerLabel: provider?.label || c.provider,
|
||||
canCheck: !!provider?.findPayments,
|
||||
amount: c.amountCents / 100,
|
||||
description: c.request?.description || meta.eventTitle || '',
|
||||
type: meta.type || null,
|
||||
registrationId: meta.registrationId || null,
|
||||
payer: users.get(meta.userId) || c.request?.payer?.name || null,
|
||||
createdAt: c.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
// @desc Online checkouts started in the last 14 days that have no payment yet
|
||||
// @route GET /api/payments/checkouts/open
|
||||
// @access Supervisor
|
||||
const listOpenCheckouts = async (req, res) => {
|
||||
try {
|
||||
const open = await openCheckouts({ since: new Date(Date.now() - OPEN_DAYS * 24 * 60 * 60 * 1000) });
|
||||
const userIds = [...new Set(open.map(c => c.metadata?.userId).filter(Boolean))];
|
||||
const users = new Map((await prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } })).map(u => [u.id, u.name]));
|
||||
res.json({ checkouts: await Promise.all(open.map(c => describeCheckout(c, users))), days: OPEN_DAYS });
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Ask the providers whether open checkouts were paid, and record what they find
|
||||
// @route POST /api/payments/checkouts/check { ids?: string[] } (default: every open one)
|
||||
// @access Supervisor
|
||||
const checkCheckoutStatus = async (req, res) => {
|
||||
try {
|
||||
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(String).slice(0, 200) : null;
|
||||
const list = ids
|
||||
? await prisma.providerCheckout.findMany({ where: { id: { in: ids } } })
|
||||
: await openCheckouts({ since: new Date(Date.now() - OPEN_DAYS * 24 * 60 * 60 * 1000) });
|
||||
const results = await checkCheckouts(list);
|
||||
const out = Object.fromEntries(results);
|
||||
const recorded = [...results.values()].filter(r => r.status === 'recorded').length;
|
||||
res.json({ results: out, recorded, message: recorded ? `Found and recorded ${recorded} payment${recorded === 1 ? '' : 's'}.` : 'No new payments found.' });
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider,
|
||||
listOpenCheckouts, checkCheckoutStatus,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { ACCESS_ACTIONS } = require('../utils/accessLog');
|
||||
const notice = require('../utils/privacyNotice');
|
||||
|
||||
const fail = (res, error) => res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const page = req => Math.max(parseInt(req.query.page, 10) || 1, 1);
|
||||
|
||||
// ─── Privacy notice ───────────────────────────────────────────────────────────
|
||||
|
||||
// @desc The privacy notice as published (current, or ?version=N), placeholders filled in
|
||||
// @route GET /api/privacy/notice
|
||||
// @access Public
|
||||
const getNotice = async (req, res) => {
|
||||
try {
|
||||
const version = req.query.version != null ? parseInt(req.query.version, 10) : null;
|
||||
if (req.query.version != null && !(version >= 0)) { res.status(400); throw new Error('Invalid version'); }
|
||||
const result = await notice.publicNotice(version);
|
||||
if (!result) { res.status(404); throw new Error('That version of the privacy notice does not exist.'); }
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Accept the current privacy notice
|
||||
// @route POST /api/privacy/me/notice/accept
|
||||
// @access Private
|
||||
const acceptMyNotice = async (req, res) => {
|
||||
try {
|
||||
if (req.impersonation) { res.status(403); throw new Error('Not available while viewing as another user.'); }
|
||||
const version = await notice.acceptNotice(req.user.id, { source: 'prompt', ip: getClientIp(req) });
|
||||
res.json({ version });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
async function versionList() {
|
||||
const [versions, counts] = await Promise.all([
|
||||
prisma.privacyNotice.findMany({
|
||||
orderBy: { version: 'desc' },
|
||||
select: { version: true, publishedAt: true, changeSummary: true, requiresAcceptance: true, publishedBy: { select: { name: true } } },
|
||||
}),
|
||||
prisma.noticeAcceptance.groupBy({ by: ['version'], _count: { _all: true } }),
|
||||
]);
|
||||
const byVersion = new Map(counts.map(c => [c.version, c._count._all]));
|
||||
return versions.map(v => ({ ...v, publishedBy: v.publishedBy?.name || null, acceptances: byVersion.get(v.version) || 0 }));
|
||||
}
|
||||
|
||||
// @desc The editor: the draft (or current text), how it reads, and the published versions
|
||||
// @route GET /api/privacy/notice/draft
|
||||
// @access Private/Admin
|
||||
const getNoticeDraft = async (req, res) => {
|
||||
try {
|
||||
const draft = await notice.getDraft();
|
||||
const ctx = await notice.noticeContext();
|
||||
res.json({
|
||||
...draft,
|
||||
rendered: notice.renderSections(draft.sections, ctx),
|
||||
placeholders: notice.PLACEHOLDERS,
|
||||
versions: await versionList(),
|
||||
builtIn: notice.defaultSections(),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Save the draft (body.sections)
|
||||
// @route PUT /api/privacy/notice/draft
|
||||
// @access Private/Admin
|
||||
const saveNoticeDraft = async (req, res) => {
|
||||
try {
|
||||
const sections = await notice.saveDraft(req.body?.sections);
|
||||
res.json({ sections, rendered: notice.renderSections(sections, await notice.noticeContext()), isDraft: true, savedAt: new Date() });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Throw the draft away (back to the current published text)
|
||||
// @route DELETE /api/privacy/notice/draft
|
||||
// @access Private/Admin
|
||||
const discardNoticeDraft = async (req, res) => {
|
||||
try {
|
||||
await notice.discardDraft();
|
||||
res.json({ message: 'Draft discarded.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Publish body.sections as the next version (body.changeSummary, body.requiresAcceptance)
|
||||
// @route POST /api/privacy/notice/publish
|
||||
// @access Private/Admin
|
||||
const publishNotice = async (req, res) => {
|
||||
try {
|
||||
const { sections, changeSummary, requiresAcceptance } = req.body || {};
|
||||
if (requiresAcceptance && !String(changeSummary || '').trim()) {
|
||||
res.status(400);
|
||||
throw new Error('Say what changed — people see it when they are asked to accept the new version.');
|
||||
}
|
||||
const published = await notice.publish({ sections, changeSummary, requiresAcceptance, publishedById: req.user.id });
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'privacy_notice_published', targetType: 'privacy_notice', targetId: published.id,
|
||||
metadata: { version: published.version, requiresAcceptance: published.requiresAcceptance, changeSummary: published.changeSummary }, ip: getClientIp(req),
|
||||
});
|
||||
res.status(201).json({ version: published.version, publishedAt: published.publishedAt, requiresAcceptance: published.requiresAcceptance });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Operators ────────────────────────────────────────────────────────────────
|
||||
|
||||
const OPERATOR_FIELDS = ['name', 'purpose', 'dataShared', 'location', 'website'];
|
||||
|
||||
function operatorData(body, { partial = false } = {}) {
|
||||
const data = {};
|
||||
for (const f of OPERATOR_FIELDS) {
|
||||
if (body?.[f] === undefined) continue;
|
||||
const v = body[f] == null ? '' : String(body[f]).trim().slice(0, f === 'name' ? 200 : 1000);
|
||||
data[f] = v || (f === 'location' || f === 'website' ? null : '');
|
||||
}
|
||||
if (typeof body?.active === 'boolean') data.active = body.active;
|
||||
if (Number.isInteger(body?.order)) data.order = body.order;
|
||||
if ((!partial || 'name' in data) && !data.name) throw new Error('Give the operator a name.');
|
||||
if ((!partial || 'purpose' in data) && !data.purpose) throw new Error('Say what the operator does for you.');
|
||||
if (data.website && !/^https?:\/\//i.test(data.website)) data.website = `https://${data.website}`;
|
||||
return data;
|
||||
}
|
||||
|
||||
// @desc The operator register
|
||||
// @route GET /api/privacy/operators
|
||||
// @access Private/Admin
|
||||
const listOperators = async (req, res) => {
|
||||
try {
|
||||
res.json(await prisma.operator.findMany({ orderBy: [{ order: 'asc' }, { name: 'asc' }] }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/privacy/operators
|
||||
const createOperator = async (req, res) => {
|
||||
try {
|
||||
const data = operatorData(req.body);
|
||||
if (data.order === undefined) data.order = (await prisma.operator.count()) + 1;
|
||||
const op = await prisma.operator.create({ data });
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'operator_created', targetType: 'operator', targetId: op.id, metadata: { name: op.name }, ip: getClientIp(req) });
|
||||
res.status(201).json(op);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route PUT /api/privacy/operators/:id
|
||||
const updateOperator = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.operator.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) { res.status(404); throw new Error('Operator not found'); }
|
||||
const op = await prisma.operator.update({ where: { id: existing.id }, data: operatorData(req.body, { partial: true }) });
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'operator_updated', targetType: 'operator', targetId: op.id, metadata: { name: op.name, active: op.active }, ip: getClientIp(req) });
|
||||
res.json(op);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/privacy/operators/:id
|
||||
const deleteOperator = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.operator.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) { res.status(404); throw new Error('Operator not found'); }
|
||||
await prisma.operator.delete({ where: { id: existing.id } });
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'operator_deleted', targetType: 'operator', targetId: existing.id, metadata: { name: existing.name }, ip: getClientIp(req) });
|
||||
res.json({ message: 'Operator removed.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Consent log ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Filter on the person's name or email.
|
||||
function personWhere(q) {
|
||||
return q ? { OR: [{ name: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }] } : undefined;
|
||||
}
|
||||
|
||||
// @desc Proof of consent: ?type=news (news opt-in changes), notice (privacy notice
|
||||
// acceptances) or parental (parental consent answers); ?q= name/email; ?page=
|
||||
// @route GET /api/privacy/consent-log
|
||||
// @access Private/Admin
|
||||
const consentLog = async (req, res) => {
|
||||
try {
|
||||
const type = String(req.query.type || 'news');
|
||||
const q = String(req.query.q || '').trim();
|
||||
const skip = (page(req) - 1) * PAGE_SIZE;
|
||||
const user = { select: { name: true, email: true } };
|
||||
|
||||
if (type === 'news') {
|
||||
const where = q ? { OR: [{ user: personWhere(q) }, { address: { contains: q, mode: 'insensitive' } }] } : {};
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.communicationConsent.findMany({ where, orderBy: { createdAt: 'desc' }, skip, take: PAGE_SIZE, include: { user } }),
|
||||
prisma.communicationConsent.count({ where }),
|
||||
]);
|
||||
return res.json({
|
||||
total, pageSize: PAGE_SIZE,
|
||||
rows: rows.map(r => ({ id: r.id, at: r.createdAt, name: r.user?.name || null, email: r.user?.email || r.address, channel: r.channel, granted: r.granted, source: r.source, ip: r.ip })),
|
||||
});
|
||||
}
|
||||
if (type === 'notice') {
|
||||
const where = q ? { user: personWhere(q) } : {};
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.noticeAcceptance.findMany({ where, orderBy: { createdAt: 'desc' }, skip, take: PAGE_SIZE, include: { user } }),
|
||||
prisma.noticeAcceptance.count({ where }),
|
||||
]);
|
||||
return res.json({
|
||||
total, pageSize: PAGE_SIZE,
|
||||
rows: rows.map(r => ({ id: r.id, at: r.createdAt, name: r.user?.name || null, email: r.user?.email || null, version: r.version, source: r.source, ip: r.ip })),
|
||||
});
|
||||
}
|
||||
if (type === 'parental') {
|
||||
const where = {
|
||||
status: { in: ['given', 'declined'] },
|
||||
...(q ? { OR: [{ attendee: { name: { contains: q, mode: 'insensitive' } } }, { parentName: { contains: q, mode: 'insensitive' } }, { parentEmail: { contains: q, mode: 'insensitive' } }, { signedName: { contains: q, mode: 'insensitive' } }] } : {}),
|
||||
};
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.consentRequest.findMany({
|
||||
where, orderBy: { respondedAt: 'desc' }, skip, take: PAGE_SIZE,
|
||||
select: { id: true, status: true, signedName: true, parentName: true, textVersion: true, photoConsent: true, respondedAt: true, ip: true, medicalPurgedAt: true, attendee: { select: { name: true } }, event: { select: { title: true } } },
|
||||
}),
|
||||
prisma.consentRequest.count({ where }),
|
||||
]);
|
||||
return res.json({
|
||||
total, pageSize: PAGE_SIZE,
|
||||
rows: rows.map(r => ({
|
||||
id: r.id, at: r.respondedAt, child: r.attendee?.name, event: r.event?.title, status: r.status, signedName: r.signedName,
|
||||
parentName: r.parentName, textVersion: r.textVersion, photoConsent: r.photoConsent, ip: r.ip, medicalDeleted: !!r.medicalPurgedAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
res.status(400);
|
||||
throw new Error('type must be news, notice or parental');
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Access log ───────────────────────────────────────────────────────────────
|
||||
|
||||
// @desc Who exported or viewed personal information in bulk (?action=, ?actorId=, ?from=, ?to=, ?page=)
|
||||
// @route GET /api/privacy/access-log
|
||||
// @access Private/Admin
|
||||
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 = {};
|
||||
if (req.query.from) where.createdAt.gte = new Date(String(req.query.from));
|
||||
if (req.query.to) where.createdAt.lte = new Date(String(req.query.to));
|
||||
}
|
||||
const [rows, total, actors] = await Promise.all([
|
||||
prisma.dataAccessLog.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page(req) - 1) * PAGE_SIZE, take: PAGE_SIZE, include: { actor: { select: { name: true } } } }),
|
||||
prisma.dataAccessLog.count({ where }),
|
||||
prisma.dataAccessLog.findMany({ where: { actorId: { not: null } }, distinct: ['actorId'], select: { actorId: true, actor: { select: { name: true } } } }),
|
||||
]);
|
||||
res.json({
|
||||
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, 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,
|
||||
saveNoticeDraft,
|
||||
discardNoticeDraft,
|
||||
publishNotice,
|
||||
listOperators,
|
||||
createOperator,
|
||||
updateOperator,
|
||||
deleteOperator,
|
||||
consentLog,
|
||||
accessLog,
|
||||
};
|
||||
@@ -0,0 +1,358 @@
|
||||
const prisma = require('../config/db');
|
||||
const { comparePassword } = require('../config/auth');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { isValidZAPhone } = require('../utils/whatsapp');
|
||||
const privacy = require('../utils/privacy');
|
||||
|
||||
const fail = (res, error) => res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
|
||||
const REQUEST_LABEL = { access: 'access to your information', correction: 'correction of your information', deletion: 'deletion of your account and personal information' };
|
||||
|
||||
function frontendUrl() {
|
||||
return String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function sendPrivacyEmail(to, subject, content) {
|
||||
const { sendMail, buildPrivacyEmail } = require('../utils/email');
|
||||
if (!to) return;
|
||||
sendMail({ to, subject, ...buildPrivacyEmail(content) }).catch(() => {});
|
||||
}
|
||||
|
||||
/** Tell the Information Officer (when set) and the admins that a request came in. */
|
||||
async function notifyAdminsOfRequest(request) {
|
||||
const to = await privacy.privacyContacts();
|
||||
const url = `${frontendUrl()}/dashboard/admin/data-requests`;
|
||||
for (const email of to) {
|
||||
sendPrivacyEmail(email, `New privacy request: ${request.type}`, {
|
||||
name: '',
|
||||
heading: 'New privacy request',
|
||||
paragraphs: [
|
||||
`${request.name} (${request.email}) asked for ${REQUEST_LABEL[request.type]}.`,
|
||||
...(request.details ? [`Their message: ${request.details}`] : []),
|
||||
`POPIA asks for an answer within 30 days — by ${request.dueAt.toLocaleDateString('en-ZA')}. Open the Data requests page to handle it: ${url}`,
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Delete my account" (password already checked by the caller): lock the account straight
|
||||
* away and file a deletion request. The personal information is removed when an admin
|
||||
* approves — registrations for upcoming events have to be sorted out first.
|
||||
*/
|
||||
async function requestAccountDeletion(user, { details = null } = {}) {
|
||||
const request = await privacy.createDataRequest({ user, type: 'deletion', details: details ? String(details).trim() : null });
|
||||
await prisma.user.update({ where: { id: user.id }, data: { isActive: false, tokenVersion: { increment: 1 }, newsEmail: false, newsWhatsApp: false } });
|
||||
await require('../utils/twoFactor').clearTrustedDevices(user.id);
|
||||
const blockers = await privacy.deletionBlockers(user.id);
|
||||
sendPrivacyEmail(user.email, 'We received your request to delete your account', {
|
||||
name: user.name,
|
||||
heading: 'Deletion request received',
|
||||
paragraphs: [
|
||||
'We received your request to delete your account and personal information. Your account has been closed, so you can no longer log in.',
|
||||
blockers.length
|
||||
? `You still have a registration for ${blockers.map(b => b.eventTitle).join(', ')}. Your information is deleted after that, or sooner if the registration is cancelled. Contact us if you'd like to cancel it.`
|
||||
: 'We will remove your personal information within 30 days and let you know when it is done.',
|
||||
'Records we must keep by law, such as payment amounts and dates, are kept without your name on them.',
|
||||
'If you did not ask for this, contact us immediately.',
|
||||
],
|
||||
});
|
||||
notifyAdminsOfRequest(request).catch(() => {});
|
||||
return request;
|
||||
}
|
||||
|
||||
// ─── The person themselves ────────────────────────────────────────────────────
|
||||
|
||||
// @desc My privacy settings: news opt-in, consent history, my requests
|
||||
// @route GET /api/privacy/me
|
||||
// @access Private
|
||||
const getMyPrivacy = async (req, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user.id },
|
||||
select: { phoneNumber: true, notificationPreference: true, newsEmail: true, newsWhatsApp: true, newsAskedAt: true },
|
||||
});
|
||||
if (!user) { res.status(404); throw new Error('User not found'); }
|
||||
const [history, requests] = await Promise.all([
|
||||
privacy.consentHistory(req.user.id),
|
||||
prisma.dataRequest.findMany({
|
||||
where: { userId: req.user.id, selfService: false },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true, type: true, status: true, details: true, response: true, createdAt: true, closedAt: true },
|
||||
}),
|
||||
]);
|
||||
res.json({
|
||||
news: { email: user.newsEmail, whatsapp: user.newsWhatsApp },
|
||||
newsAskedAt: user.newsAskedAt,
|
||||
canWhatsApp: isValidZAPhone(user.phoneNumber),
|
||||
notificationPreference: user.notificationPreference,
|
||||
history,
|
||||
requests,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Change my News & announcements opt-in
|
||||
// @route PUT /api/privacy/me/news
|
||||
// @access Private
|
||||
const updateMyNews = async (req, res) => {
|
||||
try {
|
||||
const { email, whatsapp, source } = req.body || {};
|
||||
// 'prompt' = the one-time question after login; anything else is the privacy centre.
|
||||
const fromPrompt = source === 'prompt';
|
||||
const updated = await privacy.setNewsPreferences(
|
||||
req.user.id,
|
||||
{ email: typeof email === 'boolean' ? email : undefined, whatsapp: typeof whatsapp === 'boolean' ? whatsapp : undefined },
|
||||
{ source: fromPrompt ? 'prompt' : 'privacy_centre', ip: getClientIp(req), answered: true, logAll: fromPrompt },
|
||||
);
|
||||
if (!updated) { res.status(404); throw new Error('User not found'); }
|
||||
res.json({ news: { email: updated.newsEmail, whatsapp: updated.newsWhatsApp }, newsAskedAt: updated.newsAskedAt });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Download my data (JSON, or a readable PDF with ?format=pdf)
|
||||
// @route GET /api/privacy/me/export
|
||||
// @access Private
|
||||
const exportMyData = async (req, res) => {
|
||||
try {
|
||||
// An admin viewing as this person must not walk off with their whole record.
|
||||
if (req.impersonation) { res.status(403); throw new Error('Not available while viewing as another user.'); }
|
||||
const data = await privacy.exportUserData(req.user.id);
|
||||
if (!data) { res.status(404); throw new Error('User not found'); }
|
||||
// Kept as a closed access request: the record that the person got their data.
|
||||
await privacy.createDataRequest({ user: { id: req.user.id, name: data.account.name, email: data.account.email }, type: 'access', selfService: true });
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
if (req.query.format === 'pdf') {
|
||||
const pdf = await privacy.dataExportPdf(data);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="my-data-${stamp}.pdf"`);
|
||||
return res.send(pdf);
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="my-data-${stamp}.json"`);
|
||||
res.send(JSON.stringify(data, null, 2));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Make a privacy request: correction (details required) or deletion (password required)
|
||||
// @route POST /api/privacy/me/requests
|
||||
// @access Private
|
||||
const createMyRequest = async (req, res) => {
|
||||
try {
|
||||
const { type, details, password } = req.body || {};
|
||||
if (!['correction', 'deletion'].includes(type)) { res.status(400); throw new Error('type must be correction or deletion'); }
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
|
||||
if (!user) { res.status(404); throw new Error('User not found'); }
|
||||
|
||||
const open = await prisma.dataRequest.findFirst({ where: { userId: user.id, type, status: 'open' } });
|
||||
if (open) { res.status(409); throw new Error(`You already have an open ${type} request.`); }
|
||||
|
||||
if (type === 'correction') {
|
||||
if (!details || !String(details).trim()) { res.status(400); throw new Error('Tell us what needs correcting.'); }
|
||||
const request = await privacy.createDataRequest({ user, type, details: String(details).trim() });
|
||||
notifyAdminsOfRequest(request).catch(() => {});
|
||||
return res.status(201).json({ request, message: 'Your request was sent. We will get back to you by email.' });
|
||||
}
|
||||
|
||||
if (!(await comparePassword(password || '', user.password))) { res.status(400); throw new Error('Incorrect password'); }
|
||||
const request = await requestAccountDeletion(user, { details });
|
||||
res.status(201).json({ request, message: 'Your account is closed and your deletion request was sent.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Unsubscribe links (no login) ─────────────────────────────────────────────
|
||||
|
||||
// @desc What an unsubscribe link refers to
|
||||
// @route GET /api/privacy/unsubscribe/:token
|
||||
// @access Public (signed token)
|
||||
const getUnsubscribe = async (req, res) => {
|
||||
try {
|
||||
const info = await privacy.describeUnsubscribe(req.params.token);
|
||||
if (!info) { res.status(404); throw new Error('This unsubscribe link is not valid.'); }
|
||||
res.json(info);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Unsubscribe from news (body.channels: ['email','whatsapp'], default both)
|
||||
// @route POST /api/privacy/unsubscribe/:token
|
||||
// @access Public (signed token)
|
||||
const postUnsubscribe = async (req, res) => {
|
||||
try {
|
||||
const channels = Array.isArray(req.body?.channels) ? req.body.channels : undefined;
|
||||
const result = await privacy.unsubscribe(req.params.token, { channels, ip: getClientIp(req) });
|
||||
if (!result) { res.status(404); throw new Error('This unsubscribe link is not valid.'); }
|
||||
res.json({ message: 'You have been unsubscribed.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc One-click unsubscribe (RFC 8058) from the List-Unsubscribe header — email only
|
||||
// @route POST /api/privacy/unsubscribe/:token/one-click
|
||||
// @access Public (signed token)
|
||||
const oneClickUnsubscribe = async (req, res) => {
|
||||
try {
|
||||
await privacy.unsubscribe(req.params.token, { channels: ['email'], ip: getClientIp(req) });
|
||||
} catch { /* mail apps ignore the body; never fail loudly */ }
|
||||
res.status(200).send('Unsubscribed');
|
||||
};
|
||||
|
||||
// @desc A person opening the List-Unsubscribe link in a browser: send them to the page
|
||||
// @route GET /api/privacy/unsubscribe/:token/one-click
|
||||
// @access Public
|
||||
const oneClickRedirect = (req, res) => {
|
||||
res.redirect(302, `${frontendUrl()}/unsubscribe?t=${encodeURIComponent(req.params.token)}`);
|
||||
};
|
||||
|
||||
// ─── Admin: data requests inbox ───────────────────────────────────────────────
|
||||
|
||||
// @desc Data requests (?status=open|closed|all, default open)
|
||||
// @route GET /api/privacy/requests
|
||||
// @access Private/Admin
|
||||
const listRequests = async (req, res) => {
|
||||
try {
|
||||
const status = String(req.query.status || 'open');
|
||||
const where = { selfService: false };
|
||||
if (status === 'open') where.status = 'open';
|
||||
else if (status === 'closed') where.status = { not: 'open' };
|
||||
const rows = await prisma.dataRequest.findMany({
|
||||
where,
|
||||
orderBy: status === 'open' ? { dueAt: 'asc' } : { createdAt: 'desc' },
|
||||
take: 200,
|
||||
include: { handledBy: { select: { name: true } }, user: { select: { isActive: true, phoneNumber: true } } },
|
||||
});
|
||||
const requests = await Promise.all(rows.map(async r => ({
|
||||
id: r.id, type: r.type, status: r.status, name: r.name, email: r.email, details: r.details, response: r.response,
|
||||
dueAt: r.dueAt, closedAt: r.closedAt, createdAt: r.createdAt, userId: r.userId,
|
||||
handledBy: r.handledBy?.name || null,
|
||||
overdue: r.status === 'open' && r.dueAt < new Date(),
|
||||
blockers: r.type === 'deletion' && r.status === 'open' && r.userId ? await privacy.deletionBlockers(r.userId) : [],
|
||||
})));
|
||||
const openCount = status === 'open' ? requests.length : await prisma.dataRequest.count({ where: { selfService: false, status: 'open' } });
|
||||
res.json({ requests, openCount });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
async function loadOpenRequest(req, res) {
|
||||
const request = await prisma.dataRequest.findUnique({ where: { id: req.params.id } });
|
||||
if (!request) { res.status(404); throw new Error('Request not found'); }
|
||||
if (request.status !== 'open') { res.status(409); throw new Error('This request is already closed.'); }
|
||||
return request;
|
||||
}
|
||||
|
||||
// @desc Complete a request. Deletion: anonymises the account (refused while it has a
|
||||
// registration for an upcoming event). body.response is emailed to the person.
|
||||
// @route POST /api/privacy/requests/:id/complete
|
||||
// @access Private/Admin
|
||||
const completeRequest = async (req, res) => {
|
||||
try {
|
||||
const request = await loadOpenRequest(req, res);
|
||||
const response = req.body?.response ? String(req.body.response).trim().slice(0, 5000) : null;
|
||||
if (request.type === 'correction' && !response) { res.status(400); throw new Error('Say what was corrected — it is emailed to the person.'); }
|
||||
|
||||
let counts = null;
|
||||
if (request.type === 'deletion' && request.userId) {
|
||||
const blockers = await privacy.deletionBlockers(request.userId);
|
||||
if (blockers.length) {
|
||||
res.status(409);
|
||||
throw new Error(`Cancel (and refund where needed) these registrations first: ${blockers.map(b => b.eventTitle).join(', ')}.`);
|
||||
}
|
||||
counts = await privacy.anonymiseUser(request.userId);
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'user_anonymised', targetType: 'user', targetId: request.userId,
|
||||
metadata: { dataRequestId: request.id, ...counts }, ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
|
||||
const closed = await prisma.dataRequest.update({
|
||||
where: { id: request.id },
|
||||
data: { status: 'completed', response, closedAt: new Date(), handledById: req.user.id },
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'data_request_completed', targetType: 'data_request', targetId: request.id,
|
||||
metadata: { type: request.type, name: request.name }, ip: getClientIp(req),
|
||||
});
|
||||
|
||||
sendPrivacyEmail(request.email, request.type === 'deletion' ? 'Your personal information has been deleted' : 'Your privacy request has been completed', {
|
||||
name: request.name,
|
||||
heading: request.type === 'deletion' ? 'Your information has been deleted' : 'Request completed',
|
||||
paragraphs: [
|
||||
request.type === 'deletion'
|
||||
? 'Your account and personal information have been deleted. Records we must keep by law, such as payment amounts and dates, are kept without your name on them. Backups that still contain your information are deleted as they age out.'
|
||||
: `We have completed your request for ${REQUEST_LABEL[request.type]}.`,
|
||||
...(response ? [response] : []),
|
||||
],
|
||||
});
|
||||
res.json({ request: closed, anonymised: counts });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Reject or cancel a request (body.response says why; emailed). Rejecting a
|
||||
// deletion request opens the account again (e.g. the person changed their mind).
|
||||
// @route POST /api/privacy/requests/:id/reject
|
||||
// @access Private/Admin
|
||||
const rejectRequest = async (req, res) => {
|
||||
try {
|
||||
const request = await loadOpenRequest(req, res);
|
||||
const response = req.body?.response ? String(req.body.response).trim().slice(0, 5000) : '';
|
||||
if (!response) { res.status(400); throw new Error('Give a reason — it is emailed to the person.'); }
|
||||
const cancelled = req.body?.cancelled === true;
|
||||
|
||||
if (request.type === 'deletion' && request.userId) {
|
||||
await prisma.user.update({ where: { id: request.userId }, data: { isActive: true } });
|
||||
}
|
||||
const closed = await prisma.dataRequest.update({
|
||||
where: { id: request.id },
|
||||
data: { status: cancelled ? 'cancelled' : 'rejected', response, closedAt: new Date(), handledById: req.user.id },
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'data_request_rejected', targetType: 'data_request', targetId: request.id,
|
||||
metadata: { type: request.type, name: request.name, cancelled, reactivated: request.type === 'deletion' }, ip: getClientIp(req),
|
||||
});
|
||||
sendPrivacyEmail(request.email, cancelled ? 'Your privacy request was cancelled' : 'About your privacy request', {
|
||||
name: request.name,
|
||||
heading: cancelled ? 'Request cancelled' : 'About your request',
|
||||
paragraphs: [
|
||||
`Your request for ${REQUEST_LABEL[request.type]} was ${cancelled ? 'cancelled' : 'not carried out'}.`,
|
||||
response,
|
||||
...(request.type === 'deletion' ? ['Your account is open again — you can log in as before.'] : []),
|
||||
...(cancelled ? [] : ['If you disagree, you may complain to the Information Regulator (inforegulator.org.za).']),
|
||||
],
|
||||
});
|
||||
res.json({ request: closed });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getMyPrivacy,
|
||||
updateMyNews,
|
||||
exportMyData,
|
||||
createMyRequest,
|
||||
getUnsubscribe,
|
||||
postUnsubscribe,
|
||||
oneClickUnsubscribe,
|
||||
oneClickRedirect,
|
||||
listRequests,
|
||||
completeRequest,
|
||||
rejectRequest,
|
||||
notifyAdminsOfRequest,
|
||||
requestAccountDeletion,
|
||||
};
|
||||
@@ -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).
|
||||
|
||||
@@ -397,6 +397,13 @@ function writeTableRows(ws, startRow, columns, rows) {
|
||||
return { nextRow: r, dataStartRow };
|
||||
}
|
||||
|
||||
// POPIA access log: who took which report out of the system, and how many rows.
|
||||
function logReport(req, action) {
|
||||
const { logDataAccess, reportRowCount } = require('../utils/accessLog');
|
||||
const p = req.body || {};
|
||||
logDataAccess(req, { action, what: [p.title || 'Report', p.subtitle].filter(Boolean).join(' — '), records: reportRowCount(p) });
|
||||
}
|
||||
|
||||
// POST /api/reports/pdf
|
||||
// body: { title, subtitle?, kind: 'table'|'layered', table?, layered?, stats?, chart?, note?, orientation? }
|
||||
const generatePdf = async (req, res) => {
|
||||
@@ -410,6 +417,7 @@ const generatePdf = async (req, res) => {
|
||||
doc.pipe(res);
|
||||
drawReportPdf(doc, { title, subtitle, kind, table, layered, stats, chart, note, extraTables });
|
||||
doc.end();
|
||||
logReport(req, 'report_pdf');
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: e.message });
|
||||
}
|
||||
@@ -426,6 +434,7 @@ const generateExcel = async (req, res) => {
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
await wb.xlsx.write(res);
|
||||
res.end();
|
||||
logReport(req, 'report_excel');
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: e.message });
|
||||
}
|
||||
@@ -462,6 +471,7 @@ const emailPdf = async (req, res) => {
|
||||
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
|
||||
logReport(req, 'report_email');
|
||||
res.json({ message: `Report emailed to ${user.email}` });
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: e.message });
|
||||
@@ -489,6 +499,7 @@ const whatsappPdf = async (req, res) => {
|
||||
const filename = `${(title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
|
||||
await sendPdf(user.phoneNumber, filePath, filename, caption || title || 'Report');
|
||||
|
||||
logReport(req, 'report_whatsapp');
|
||||
res.json({ message: `Report sent to your WhatsApp` });
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: e.message });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,9 +8,19 @@ const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
|
||||
// Keys safe to return without auth — includes legal keys needed by public legal pages
|
||||
// Yoco secrets are only readable (masked) and writable through /api/yoco — never
|
||||
// through the generic settings endpoints.
|
||||
const YOCO_PRIVATE_KEYS = new Set(['yoco_secret_key', 'yoco_webhook_secret', 'yoco_webhook_id']);
|
||||
// Payment provider settings are only readable (secrets masked) and writable through
|
||||
// /api/yoco and /api/payfast — never through the generic settings endpoints.
|
||||
const PAYMENT_PRIVATE_KEYS = new Set([
|
||||
'yoco_secret_key', 'yoco_webhook_secret', 'yoco_webhook_id',
|
||||
'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',
|
||||
@@ -50,7 +60,7 @@ const getAllSettings = async (req, res) => {
|
||||
const rows = await prisma.appSetting.findMany();
|
||||
const settings = {};
|
||||
for (const r of rows) {
|
||||
if (YOCO_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
|
||||
@@ -86,7 +96,7 @@ const updateSettings = async (req, res) => {
|
||||
}
|
||||
|
||||
const ops = Object.entries(updates)
|
||||
.filter(([k, v]) => v !== undefined && v !== null && !YOCO_PRIVATE_KEYS.has(k))
|
||||
.filter(([k, v]) => v !== undefined && v !== null && !PRIVATE_KEYS.has(k))
|
||||
.map(([key, value]) => {
|
||||
let storedValue = String(value);
|
||||
|
||||
@@ -125,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 && !YOCO_PRIVATE_KEYS.has(k)) },
|
||||
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !PRIVATE_KEYS.has(k)) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
@@ -202,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() !== '' && !YOCO_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);
|
||||
|
||||
@@ -84,6 +84,8 @@ const createSponsorCheckout = async (req, res) => {
|
||||
cancelUrl: back,
|
||||
failureUrl: `${back}?sponsored=failed`,
|
||||
lineItems: [{ name: label, quantity: 1, unitCents: amountInCents }],
|
||||
description: label,
|
||||
payer: { name: payer.name || '', email: payer.email || '' },
|
||||
idempotencyKey: `spon_${event.id}_${payer.id}_${amountInCents}_${Date.now()}`,
|
||||
});
|
||||
res.json({ redirectUrl: checkout.redirectUrl, checkoutId: checkout.checkoutId, provider: checkout.provider, amount: charge.amount });
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -139,7 +139,8 @@ async function sendWelcomeEmail(user) {
|
||||
// Fields the login flow needs once the password (and any 2FA code) has checked out.
|
||||
const LOGIN_USER_SELECT = {
|
||||
id: true, name: true, email: true, role: true, tokenVersion: true, isActive: true,
|
||||
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true,
|
||||
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true, newsAskedAt: true,
|
||||
noticeVersionAccepted: true,
|
||||
};
|
||||
|
||||
// Errors from utils/twoFactor carry their own HTTP status; everything else follows
|
||||
@@ -150,7 +151,11 @@ function errorStatus(error, res) {
|
||||
|
||||
// Final step of a successful login (after the password, and the 2FA code when
|
||||
// required): notify, record the activity, and issue the session token.
|
||||
function completeLogin(user, req, res, extra = {}) {
|
||||
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(() => {});
|
||||
logSecurityEvent({ userId: user.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
@@ -161,6 +166,10 @@ function completeLogin(user, req, res, extra = {}) {
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
token: generateToken(user.id, user.role, user.tokenVersion),
|
||||
// POPIA: ask about News & announcements once (see NewsPrompt on the frontend), and to
|
||||
// accept a privacy notice version marked as a significant change (NoticePrompt).
|
||||
newsPromptPending: !user.newsAskedAt,
|
||||
noticePromptPending,
|
||||
...extra
|
||||
});
|
||||
}
|
||||
@@ -170,7 +179,7 @@ function completeLogin(user, req, res, extra = {}) {
|
||||
// @access Public
|
||||
const registerUser = async (req, res) => {
|
||||
try {
|
||||
const { name, email, password, phoneNumber, notificationPreference } = req.body;
|
||||
const { name, email, password, phoneNumber, notificationPreference, newsOptIn } = req.body;
|
||||
|
||||
// Normalize phone using SA-aware normalisation
|
||||
const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp');
|
||||
@@ -215,6 +224,18 @@ const registerUser = async (req, res) => {
|
||||
});
|
||||
|
||||
if (user) {
|
||||
// POPIA: the sign-up form's (unticked) "news and announcements" box. Recorded either
|
||||
// way, so they aren't asked again at their first login.
|
||||
await require('../utils/privacy').setNewsPreferences(
|
||||
user.id,
|
||||
{ email: newsOptIn === true, whatsapp: newsOptIn === true },
|
||||
{ source: 'signup', ip: getClientIp(req), answered: true, logAll: true },
|
||||
).catch(e => console.warn('[signup] news preference not saved:', e?.message || e));
|
||||
// The sign-up form requires ticking "I agree to the Terms of Use and Privacy Policy".
|
||||
await require('../utils/privacyNotice').acceptNotice(user.id, { source: 'signup', ip: getClientIp(req) })
|
||||
.catch(e => console.warn('[signup] notice acceptance not saved:', e?.message || e));
|
||||
|
||||
|
||||
// Send welcome email in the background — don't block the response
|
||||
sendWelcomeEmail(user).catch(() => {});
|
||||
|
||||
@@ -340,7 +361,7 @@ const loginUser = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
completeLogin(updated, req, res);
|
||||
await completeLogin(updated, req, res);
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
@@ -375,7 +396,7 @@ const verifyLoginTwoFactor = async (req, res) => {
|
||||
if (rememberDevice === true) {
|
||||
extra.deviceToken = await issueTrustedDevice(user.id, req.headers['user-agent']);
|
||||
}
|
||||
completeLogin(user, req, res, extra);
|
||||
await completeLogin(user, req, res, extra);
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
@@ -515,12 +536,21 @@ const getUserProfile = async (req, res) => {
|
||||
twoFactorMethod: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
homeCampusId: true,
|
||||
newsAskedAt: true,
|
||||
noticeVersionAccepted: true
|
||||
}
|
||||
});
|
||||
|
||||
if (user) {
|
||||
res.json({ ...user, twoFactorRequired: requiresTwoFactor(user) });
|
||||
const { newsAskedAt, noticeVersionAccepted, ...rest } = user;
|
||||
// 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 });
|
||||
// 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');
|
||||
@@ -544,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;
|
||||
@@ -592,6 +633,7 @@ const updateUserProfile = async (req, res) => {
|
||||
phoneNumber: newPhone,
|
||||
notificationPreference: newPref,
|
||||
twoFactorMethod: newTwoFactorMethod,
|
||||
homeCampusId: newHomeCampusId,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
select: {
|
||||
@@ -606,6 +648,7 @@ const updateUserProfile = async (req, res) => {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true,
|
||||
homeCampusId: true,
|
||||
tokenVersion: true
|
||||
}
|
||||
});
|
||||
@@ -742,7 +785,8 @@ const getUserById = async (req, res) => {
|
||||
phoneNumber: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
scopedCampuses: { select: { id: true, name: true } }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -771,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');
|
||||
@@ -807,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,
|
||||
@@ -820,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);
|
||||
@@ -874,21 +937,22 @@ const anonymizeUser = async (req, res) => {
|
||||
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
|
||||
if (!user) { res.status(404); throw new Error('User not found'); }
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
name: 'Deleted User',
|
||||
email: `deleted-${req.params.id}@deleted.local`,
|
||||
phoneNumber: null,
|
||||
isActive: false,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorMethod: null,
|
||||
tokenVersion: { increment: 1 },
|
||||
updatedAt: new Date()
|
||||
}
|
||||
const { deletionBlockers, anonymiseUser } = require('../utils/privacy');
|
||||
const blockers = await deletionBlockers(user.id);
|
||||
if (blockers.length) {
|
||||
res.status(409);
|
||||
throw new Error(`Cancel (and refund where needed) these registrations first: ${blockers.map(b => b.eventTitle).join(', ')}.`);
|
||||
}
|
||||
const counts = await anonymiseUser(user.id);
|
||||
// An open deletion request from this person is now done.
|
||||
await prisma.dataRequest.updateMany({
|
||||
where: { userId: user.id, type: 'deletion', status: 'open' },
|
||||
data: { status: 'completed', closedAt: new Date(), handledById: req.user.id },
|
||||
});
|
||||
require('../utils/adminAudit').logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'user_anonymised', targetType: 'user', targetId: user.id,
|
||||
metadata: counts, ip: getClientIp(req),
|
||||
});
|
||||
await clearTrustedDevices(req.params.id);
|
||||
await require('../utils/consent').clearConsentDataForUser(req.params.id);
|
||||
|
||||
res.json({ message: 'User data deleted' });
|
||||
} catch (error) {
|
||||
@@ -1134,34 +1198,11 @@ const closeAccount = async (req, res) => {
|
||||
const realName = user.name;
|
||||
|
||||
if (deleteData) {
|
||||
// Anonymise: wipe all personal data while keeping the row intact for referential integrity
|
||||
await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: {
|
||||
name: 'Deleted User',
|
||||
email: `deleted-${uuidv4()}@deleted.invalid`,
|
||||
password: '',
|
||||
phoneNumber: null,
|
||||
isActive: false,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorMethod: null,
|
||||
tokenVersion: { increment: 1 },
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await clearTrustedDevices(req.user.id);
|
||||
await require('../utils/consent').clearConsentDataForUser(req.user.id);
|
||||
// Send closure confirmation to the real address (before it was wiped)
|
||||
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
|
||||
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
|
||||
const { waText } = require('../utils/notify');
|
||||
const { buildWAAccountClosed } = require('../utils/waMessages');
|
||||
const content = buildAccountClosedEmail({ name: realName, dataDeleted: true });
|
||||
sendMail({ to: realEmail, subject: `Your ${getOrgName()} account has been closed`, ...content }).catch(() => {});
|
||||
// WhatsApp while we still have phone (send before data wipe completes in-flight)
|
||||
waText(user, buildWAAccountClosed({ name: realName, dataDeleted: true })).catch(() => {});
|
||||
}
|
||||
return res.json({ message: 'Your account and personal data have been removed.' });
|
||||
// POPIA: deleting personal data is a request an admin approves (see privacyController);
|
||||
// the account is closed straight away.
|
||||
const open = await prisma.dataRequest.findFirst({ where: { userId: user.id, type: 'deletion', status: 'open' } });
|
||||
if (!open) await require('./privacyController').requestAccountDeletion(user);
|
||||
return res.json({ message: 'Your account is closed. Your personal information will be deleted once your request has been processed — we will email you.' });
|
||||
} else {
|
||||
// Soft-deactivate only
|
||||
await prisma.user.update({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
const prisma = require('../config/db');
|
||||
const { normaliseKind, filterRecipients, whatsAppNewsFooter } = require('../utils/privacy');
|
||||
|
||||
function fmtDate(d) {
|
||||
try { return new Date(d).toLocaleString(); } catch { return String(d); }
|
||||
@@ -82,9 +83,10 @@ const previewWhatsAppBroadcast = async (req, res) => {
|
||||
if (!phone) continue;
|
||||
if (!map.has(phone)) map.set(phone, { phone, name: r.name || '' });
|
||||
}
|
||||
const recipients = Array.from(map.values());
|
||||
const kind = normaliseKind(req.body?.kind) || 'news';
|
||||
const { recipients, skipped } = await filterRecipients(Array.from(map.values()), { channel: 'whatsapp', kind });
|
||||
|
||||
return res.json({ matched: recipients.length, recipients: recipients.slice(0, 20) });
|
||||
return res.json({ kind, matched: recipients.length, skipped, recipients: recipients.slice(0, 20) });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -123,9 +125,12 @@ const sendWhatsAppBroadcast = async (req, res) => {
|
||||
if (!phone) continue;
|
||||
if (!map.has(phone)) map.set(phone, { phone, name: r.name || '' });
|
||||
}
|
||||
const recipients = Array.from(map.values());
|
||||
// News unless marked as an event update; news skips people who haven't opted in to
|
||||
// WhatsApp news, replied STOP or unsubscribed.
|
||||
const kind = normaliseKind(req.body?.kind) || 'news';
|
||||
const { recipients, skipped } = await filterRecipients(Array.from(map.values()), { channel: 'whatsapp', kind });
|
||||
if (recipients.length === 0) {
|
||||
return res.status(400).json({ message: 'No valid recipients with phone numbers' });
|
||||
return res.status(400).json({ message: skipped ? 'Everyone selected has unsubscribed from WhatsApp news.' : 'No valid recipients with phone numbers' });
|
||||
}
|
||||
|
||||
let event = null;
|
||||
@@ -139,7 +144,7 @@ const sendWhatsAppBroadcast = async (req, res) => {
|
||||
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
||||
const ctx = { name: rcpt.name || '', eventTitle, eventStart, eventLink };
|
||||
const finalMessage = replacePlaceholders(message, ctx);
|
||||
await sendText(rcpt.phone, finalMessage);
|
||||
await sendText(rcpt.phone, kind === 'news' ? finalMessage + whatsAppNewsFooter(rcpt) : finalMessage);
|
||||
}));
|
||||
|
||||
const sent = results.filter(r => r.status === 'fulfilled').length;
|
||||
@@ -149,7 +154,7 @@ const sendWhatsAppBroadcast = async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({ matched: recipients.length, sent });
|
||||
return res.json({ kind, matched: recipients.length, skipped, sent });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -160,13 +165,13 @@ const sendWhatsAppBroadcast = async (req, res) => {
|
||||
// @access Private/Supervisor or Admin
|
||||
const scheduleWhatsAppBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { scheduledAt, message, userIds, phones, eventId } = req.body || {};
|
||||
const { scheduledAt, message, userIds, phones, eventId, kind } = req.body || {};
|
||||
if (!scheduledAt) return res.status(400).json({ message: 'scheduledAt is required' });
|
||||
const when = new Date(scheduledAt);
|
||||
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
|
||||
if (!message) return res.status(400).json({ message: 'message is required' });
|
||||
|
||||
const payload = { message, userIds, phones, eventId };
|
||||
const payload = { message, userIds, phones, eventId, kind };
|
||||
|
||||
const recipientSummary = await describeBroadcastRecipients({ userIds, phones });
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ const getConfigHandler = async (req, res) => {
|
||||
hasToken: !!token,
|
||||
hasInstance: !!instanceId,
|
||||
configured: !!(token && instanceId),
|
||||
webhookUrl: await require('../utils/whatsapp').webhookUrl(),
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.message || 'Failed to load config' });
|
||||
@@ -162,6 +163,7 @@ const handleWebhook = async (req, res) => {
|
||||
|
||||
try {
|
||||
const { event, session } = req.body || {};
|
||||
if (event === 'message') return await handleIncomingMessage(req);
|
||||
if (event !== 'session.status') return;
|
||||
|
||||
const status = session?.status;
|
||||
@@ -210,6 +212,42 @@ const handleWebhook = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* An incoming WhatsApp message (WAWP `message` event). The only thing we act on is an
|
||||
* opt-out reply — "STOP" — which stops News & announcements on WhatsApp for that number
|
||||
* (event updates still go out). Only trusted when the URL carries our webhook secret,
|
||||
* since WAWP doesn't sign webhooks.
|
||||
*/
|
||||
async function handleIncomingMessage(req) {
|
||||
const crypto = require('crypto');
|
||||
const { getWebhookSecret, phoneFromChatId, sendText } = require('../utils/whatsapp');
|
||||
const { isStopKeyword, whatsAppStop } = require('../utils/privacy');
|
||||
|
||||
const given = String(req.query?.secret || '');
|
||||
const secret = await getWebhookSecret();
|
||||
if (given.length !== secret.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(secret))) {
|
||||
console.warn('[whatsapp webhook] Ignored a message event without a valid secret');
|
||||
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);
|
||||
if (!phone) {
|
||||
// Groups, channels, and senders WhatsApp only identifies by a "LID" (no phone number).
|
||||
console.warn('[whatsapp webhook] STOP from a sender without a phone number:', payload.from);
|
||||
return;
|
||||
}
|
||||
const result = await whatsAppStop(phone);
|
||||
if (!result) return;
|
||||
await sendText(phone, "You won't get news and announcements from us on WhatsApp any more. You'll still get messages about events you've registered for.").catch(() => {});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getConfigHandler,
|
||||
saveConfigHandler,
|
||||
|
||||
@@ -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 };
|
||||
+208
-4
@@ -147,6 +147,8 @@ const statsRoutes = require('./routes/statsRoutes');
|
||||
const adminRoutes = require('./routes/adminRoutes');
|
||||
const backupRoutes = require('./routes/backupRoutes');
|
||||
const yocoRoutes = require('./routes/yocoRoutes');
|
||||
const payfastRoutes = require('./routes/payfastRoutes');
|
||||
const privacyRoutes = require('./routes/privacyRoutes');
|
||||
|
||||
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
|
||||
app.use('/api/webhooks', webhookRoutes);
|
||||
@@ -154,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);
|
||||
@@ -172,6 +202,7 @@ app.use('/api/scheduled-emails', scheduledEmailRoutes);
|
||||
app.use('/api/sections', sectionRoutes);
|
||||
app.use('/api/banner', bannerRoutes);
|
||||
app.use('/api/whatsapp', whatsappRoutes);
|
||||
app.use('/api/privacy', privacyRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/setup', setupRoutes);
|
||||
app.use('/api/stats', statsRoutes);
|
||||
@@ -190,6 +221,7 @@ app.use('/api/attendance', require('./routes/attendanceRoutes'));
|
||||
app.use('/api/analytics', require('./routes/analyticsRoutes'));
|
||||
app.use('/api/backups', backupRoutes);
|
||||
app.use('/api/yoco', yocoRoutes);
|
||||
app.use('/api/payfast', payfastRoutes);
|
||||
|
||||
// Pre-warm the settings cache so synchronous helpers have DB values from startup
|
||||
const { getSettingSync, warmCache } = require('./utils/settingsCache');
|
||||
@@ -488,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:[
|
||||
@@ -509,7 +541,7 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'POST', path:'/api/users/:id/revoke-sessions', auth:'admin', desc:'Revoke all sessions for another user',
|
||||
pathParams:{ ':id':'User UUID' },
|
||||
responses:[{ status:200, desc:'Success', body:{ message:'Sessions revoked for user.' }}]},
|
||||
{ method:'POST', path:'/api/users/:id/anonymize', auth:'admin', desc:'Erase personal data for a user — sets name to "Deleted User", email to deleted-{id}@deleted.local, clears phone number, deactivates account, revokes all sessions. Irreversible.',
|
||||
{ method:'POST', path:'/api/users/:id/anonymize', auth:'admin', desc:'Erase personal data for a user (POPIA) — the account, the people they registered, form answers, consent details, sponsor names, login history and payer details in provider payloads; payments, registrations and tickets stay as "Deleted User". Refused (409) while they have a registration for an upcoming event. Completes an open deletion request. Irreversible; audited as user_anonymised.',
|
||||
pathParams:{ ':id':'User UUID' },
|
||||
responses:[
|
||||
{ status:200, desc:'Anonymised', body:{ message:'User data deleted' }},
|
||||
@@ -801,6 +833,10 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'POST', path:'/api/payments/checkout/send', auth:'supervisor+', desc:'Send a generated payment link to the registrant by email or WhatsApp (also at /api/payments/yoco-checkout/send)', request:{ body:{ registrationId:'reg-uuid-...', redirectUrl:'https://pay.yoco.com/...', channel:'whatsapp' }}},
|
||||
{ method:'GET', path:'/api/payments/providers', auth:'—', desc:'Payment providers people can pay with now (enabled in Site settings → Payments and set up), in display order, plus the online minimum (R15)',
|
||||
responses:[{ status:200, desc:'Success', body:{ providers:[{ id:'yoco', label:'Yoco' }], minimumAmount:15 }}]},
|
||||
{ method:'GET', path:'/api/payments/pay/:id', auth:'— (checkout id)', desc:'The payer\'s page for a checkout: amount, description, providers to choose from, or the PayFast form to post' },
|
||||
{ method:'POST', path:'/api/payments/pay/:id', auth:'— (checkout id)', desc:'Start the checkout with the provider the payer picked: { provider } → { redirectUrl } or { form }' },
|
||||
{ method:'GET', path:'/api/payments/checkouts/open', auth:'supervisor+', desc:'Online checkouts from the last 14 days with no payment yet, and whether their provider can be asked (canCheck)' },
|
||||
{ method:'POST', path:'/api/payments/checkouts/check', auth:'supervisor+', desc:'Ask providers whether checkouts were paid ({ ids? }, default all open) and record what they received. PayFast: transaction history; Yoco: cant_check' },
|
||||
{ method:'GET', path:'/api/payments/providers/settings', auth:'admin', desc:'Every provider with enabled, configured and capabilities ({ refund, getTransaction, listTransactions }), in display order' },
|
||||
{ method:'PUT', path:'/api/payments/providers/settings', auth:'admin', desc:'Turn providers on/off and set their order (the list order). Audit logged.',
|
||||
request:{ body:{ providers:[{ id:'yoco', enabled:true }] }}},
|
||||
@@ -878,6 +914,165 @@ 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:[] }}]},
|
||||
{ method:'PUT', path:'/api/privacy/me/news', auth:'user', desc:'Change the News & announcements opt-in per channel (source "prompt" for the one-time question after login)',
|
||||
request:{ body:{ email:false, whatsapp:false } },
|
||||
responses:[{ status:200, desc:'Saved', body:{ news:{ email:false, whatsapp:false }, newsAskedAt:'2026-10-23T08:00:00.000Z' }}]},
|
||||
{ method:'GET', path:'/api/privacy/me/export', auth:'user', desc:'Download my data — JSON, or ?format=pdf for a readable PDF. Blocked while impersonating',
|
||||
responses:[{ status:200, desc:'File download' }]},
|
||||
{ method:'POST', path:'/api/privacy/me/requests', auth:'user', desc:'Correction request ({ type:"correction", details }) or delete my account ({ type:"deletion", password }) — deletion closes the account at once',
|
||||
request:{ body:{ type:'correction', details:'My son\'s date of birth on the Camp registration is wrong — it should be 3 May 2012.' } },
|
||||
responses:[{ status:201, desc:'Created', body:{ request:{ id:'req-uuid-...', type:'correction', status:'open', dueAt:'2026-11-22T08:00:00.000Z' }, message:'Your request was sent.' }}, { status:409, desc:'Already open', body:{ message:'You already have an open correction request.' }}]},
|
||||
{ method:'GET', path:'/api/privacy/unsubscribe/:token', auth:'public', desc:'What an unsubscribe link (signed token) is for',
|
||||
responses:[{ status:200, desc:'Success', body:{ kind:'account', email:'jo***@example.com', hasPhone:true, news:{ email:true, whatsapp:true } }}]},
|
||||
{ method:'POST', path:'/api/privacy/unsubscribe/:token', auth:'public', desc:'Unsubscribe from news ({ channels?: ["email","whatsapp"] }, default both)',
|
||||
responses:[{ status:200, desc:'Done', body:{ message:'You have been unsubscribed.' }}]},
|
||||
{ method:'POST', path:'/api/privacy/unsubscribe/:token/one-click', auth:'public', desc:'RFC 8058 one-click unsubscribe from the List-Unsubscribe email header (email news off)',
|
||||
responses:[{ status:200, desc:'Done' }]},
|
||||
{ method:'GET', path:'/api/privacy/requests', auth:'admin', desc:'Data requests inbox (?status=open|closed|all), with due dates and, for deletions, the registrations still blocking it',
|
||||
responses:[{ status:200, desc:'Success', body:{ requests:[{ id:'req-uuid-...', type:'deletion', status:'open', name:'Jane Doe', email:'jane@example.com', dueAt:'2026-11-22T08:00:00.000Z', overdue:false, blockers:[] }], openCount:1 }}]},
|
||||
{ method:'POST', path:'/api/privacy/requests/:id/complete', auth:'admin', desc:'Complete a request (emailed to the person). Deletion anonymises the account; 409 while it has a registration for an upcoming event',
|
||||
request:{ body:{ response:'Corrected the date of birth on the Camp 2026 registration.' } },
|
||||
responses:[{ status:200, desc:'Completed', body:{ request:{ status:'completed' }, anonymised:null }}]},
|
||||
{ method:'POST', path:'/api/privacy/requests/:id/reject', auth:'admin', desc:'Reject a request, or mark it withdrawn (cancelled:true); a deletion request reopens the account',
|
||||
request:{ body:{ response:'We could not find a registration with that name.', cancelled:false } },
|
||||
responses:[{ status:200, desc:'Closed', body:{ request:{ status:'rejected' } }}]},
|
||||
{ method:'GET', path:'/api/privacy/notice', auth:'public', desc:'The privacy notice (current, or ?version=N) with org details and the operator list filled in. Version 0 = built-in, until one is published',
|
||||
responses:[{ status:200, desc:'Success', body:{ version:2, builtIn:false, current:true, effectiveDate:'23 October 2026', changeSummary:'We added WhatsApp news.', sections:[{ id:'introduction', title:'1. Introduction', body:'Hope Church values your privacy…' }], versions:[{ version:2, publishedAt:'2026-10-23T10:00:00.000Z', requiresAcceptance:true }] }}]},
|
||||
{ method:'POST', path:'/api/privacy/me/notice/accept', auth:'user', desc:'Accept the current privacy notice version',
|
||||
responses:[{ status:200, desc:'Accepted', body:{ version:2 }}]},
|
||||
{ method:'GET', path:'/api/privacy/notice/draft', auth:'admin', desc:'Notice editor: draft (or current) sections, rendered preview, published versions with acceptance counts, built-in text',
|
||||
responses:[{ status:200, desc:'Success', body:{ sections:[], rendered:[], isDraft:false, savedAt:null, versions:[{ version:1, acceptances:12 }] }}]},
|
||||
{ method:'PUT', path:'/api/privacy/notice/draft', auth:'admin', desc:'Save the draft ({ sections: [{ id?, title, body }] })',
|
||||
request:{ body:{ sections:[{ title:'1. Introduction', body:'{org} values your privacy.' }] } },
|
||||
responses:[{ status:200, desc:'Saved', body:{ isDraft:true }}]},
|
||||
{ method:'DELETE', path:'/api/privacy/notice/draft', auth:'admin', desc:'Discard the draft',
|
||||
responses:[{ status:200, desc:'Discarded', body:{ message:'Draft discarded.' }}]},
|
||||
{ method:'POST', path:'/api/privacy/notice/publish', auth:'admin', desc:'Publish the next version; requiresAcceptance asks everyone to accept it after their next login (needs changeSummary)',
|
||||
request:{ body:{ sections:[{ title:'1. Introduction', body:'{org} values your privacy.' }], changeSummary:'We added WhatsApp news.', requiresAcceptance:true } },
|
||||
responses:[{ status:201, desc:'Published', body:{ version:2, requiresAcceptance:true }}]},
|
||||
{ method:'GET', path:'/api/privacy/operators', auth:'admin', desc:'The operator register (third parties that process personal information)',
|
||||
responses:[{ status:200, desc:'Success', body:[{ id:'op-uuid-...', name:'PayFast', purpose:'Online payments', dataShared:'Name, email address, payment amount and reference', location:'South Africa', website:'https://payfast.io', active:true }] }]},
|
||||
{ method:'POST', path:'/api/privacy/operators', auth:'admin', desc:'Add an operator (PUT/DELETE /api/privacy/operators/:id to change or remove)',
|
||||
request:{ body:{ name:'Mailgun', purpose:'Sending emails', dataShared:'Name, email address, message content', location:'EU' } },
|
||||
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/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 }] }}]},
|
||||
]},
|
||||
|
||||
{ title: 'Broadcasts (Email)', base: '/api/broadcasts', endpoints: [
|
||||
{ method:'POST', path:'/api/broadcasts/preview', auth:'supervisor+', desc:'Dry-run a broadcast — returns resolved recipient list without sending',
|
||||
request:{ body:{ userIds:['user-uuid-1','user-uuid-2'], emails:'extra@example.com\nJohn <john@example.com>', eventId:'ev-uuid-...' }},
|
||||
@@ -1063,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' },
|
||||
@@ -1071,6 +1266,7 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'GET', path:'/api/backups/:filename/download', auth:'admin', desc:'Download a backup file' },
|
||||
]},
|
||||
{ title: 'Webhooks', base: '/api/webhooks', endpoints: [
|
||||
{ method:'POST', path:'/api/webhooks/payfast', auth:'PayFast ITN', desc:'PayFast payment notification (form-encoded). Checked as PayFast requires: signature, PayFast host, VALID from /eng/query/validate, and amount_gross equal to the checkout (otherwise held for review). COMPLETE records the payment.' },
|
||||
{ method:'POST', path:'/api/webhooks/yoco', auth:'HMAC', desc:'Yoco payment event webhook. Called by Yoco servers. Verified via HMAC-SHA256 signature in the X-Yoco-Signature header.',
|
||||
notes:'This endpoint bypasses the global rate limiter. Raw body is parsed before JSON middleware. Refreshes early-bird pricing before computing totalDue to ensure accurate paid/partial status.',
|
||||
request:{ body:{ id:'evt_yoco_abc', type:'payment.succeeded', payload:{ metadata:{ checkoutId:'checkout-id-...' }, amount:45000, currency:'ZAR' }}},
|
||||
@@ -1079,6 +1275,11 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:200, desc:'Acknowledged', body:{ received:true }}]},
|
||||
]},
|
||||
|
||||
{ title: 'PayFast', base: '/api/payfast', endpoints: [
|
||||
{ method:'GET', path:'/api/payfast/config', auth:'admin', desc:'PayFast settings (passphrase masked), mode and the ITN notify URL (with a warning when PayFast can\'t reach it).' },
|
||||
{ method:'PUT', path:'/api/payfast/config', auth:'admin', desc:'Save merchantId, merchantKey, passphrase (encrypted) and/or mode (sandbox|live). Audit logged.' },
|
||||
{ method:'POST', path:'/api/payfast/test', auth:'admin', desc:'Check the saved merchant ID and passphrase with PayFast (signed API ping).' },
|
||||
]},
|
||||
{ title: 'Yoco', base: '/api/yoco', endpoints: [
|
||||
{ method:'GET', path:'/api/yoco/config', auth:'admin', desc:'Yoco settings with secrets masked (prefix + last 3 characters), where each comes from (db, env or none), test/live mode and the default webhook URL.',
|
||||
responses:[{ status:200, desc:'Success', body:{ secretKeyMasked:'sk_test_***************aba', secretKeySource:'db', mode:'test', webhookSecretMasked:'whsec_*****************1Xz', webhookSecretSource:'db', webhookId:'sub_123', webhookUrl:'https://api.example.com/api/webhooks/yoco', configured:true }}]},
|
||||
@@ -1458,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';
|
||||
|
||||
@@ -29,6 +29,10 @@ const JOBS = [
|
||||
name: 'waitlist', label: 'Waitlist: expire offers, offer freed places', cron: '*/10 * * * *', env: 'WAITLIST_JOB_ENABLED',
|
||||
run: () => require('../utils/waitlist').runWaitlistJob(),
|
||||
},
|
||||
{
|
||||
name: 'payment-status', label: 'Payments: check providers for notifications that never arrived', cron: '*/15 * * * *', env: 'PAYMENT_STATUS_JOB_ENABLED',
|
||||
run: () => require('../payments/status').runPaymentStatusJob(),
|
||||
},
|
||||
{
|
||||
name: 'feedback', label: 'Post-event feedback forms and reminders', cron: '*/15 * * * *', env: 'FEEDBACK_JOB_ENABLED',
|
||||
run: () => require('../utils/feedback').runFeedbackJob(),
|
||||
@@ -41,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 () => {
|
||||
@@ -48,6 +56,18 @@ const JOBS = [
|
||||
return { reminded: await sendConsentReminders(new Date()), purged: await purgeConsentMedicalData(new Date()) };
|
||||
},
|
||||
},
|
||||
{
|
||||
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() }),
|
||||
@@ -231,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
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Online checkouts for registrations (the balance, part of it, or the balance plus a
|
||||
* donation). Used by the payment pages, payment links in emails and invoices, and the
|
||||
* self-service kiosk. The provider is the one asked for or the first available one
|
||||
* (./index.js); the registration remembers the checkout so the webhook can find it.
|
||||
* self-service kiosk. The provider is the one asked for, the only available one, or the
|
||||
* payer's choice on /pay/<id> (./index.js); the registration remembers the checkout so the
|
||||
* webhook can find it.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
@@ -76,6 +77,8 @@ async function createRegistrationCheckout(registrationId, userId, { successUrl,
|
||||
},
|
||||
lineItems,
|
||||
successUrl, cancelUrl, failureUrl,
|
||||
payer: { name: registration.user?.name || '', email: registration.user?.email || '' },
|
||||
description: registration.event.title,
|
||||
idempotencyKey: `reg_${registrationId}_${amountCents}_${Date.now()}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,29 +1,37 @@
|
||||
/**
|
||||
* Online payment providers.
|
||||
*
|
||||
* Every provider implements the same interface (see providers/yoco.js):
|
||||
* Every provider implements the same interface (see providers/yoco.js, providers/payfast.js):
|
||||
* id, label
|
||||
* capabilities { refund, getTransaction, listTransactions } — what its API supports
|
||||
* isConfigured() → Promise<boolean> credentials are set
|
||||
* createCheckout(input) → Promise<{ checkoutId, redirectUrl }>
|
||||
* input: { amountCents, currency, metadata, lineItems, successUrl,
|
||||
* cancelUrl, failureUrl, idempotencyKey }; lineItems:
|
||||
* [{ name, quantity, unitCents }] (negative unitCents = a deduction)
|
||||
* createCheckout(input) → Promise<{ externalId?, redirectUrl? } | { form: { action, fields } }>
|
||||
* input: { checkoutId, amountCents, currency, metadata, lineItems,
|
||||
* successUrl, cancelUrl, failureUrl, payer?, description,
|
||||
* idempotencyKey }; lineItems: [{ name, quantity, unitCents }]
|
||||
* (negative unitCents = a deduction). A provider that takes the
|
||||
* payer with a signed form (PayFast) returns { form }; the payer's
|
||||
* browser posts it from /pay/<checkoutId>.
|
||||
* verifyWebhook(req) → Promise<{ ok: true } | { ok: false, status, message, details? }>
|
||||
* parseWebhook(body) → { type: 'payment.succeeded' | <other>, transaction }
|
||||
* body: the request body as the provider sent it (JSON or form)
|
||||
* transaction: { externalId, amountCents, currency, createdDate,
|
||||
* checkoutId, methodType, metadata, status }
|
||||
* readWebhookBody(raw) → (optional) turns req.rawBody into the body; default JSON.parse
|
||||
* getTransaction / refund / listTransactions — only when the capability is true
|
||||
*
|
||||
* Admins switch providers on and off and set their order in Site settings → Payments
|
||||
* (AppSetting `payment_providers`). People pay with the first enabled provider that's
|
||||
* configured, unless they pick one. Provider fees are never deducted or added: payments are
|
||||
* recorded at the full amount and fees are handled at cashup.
|
||||
* Every checkout is recorded as a ProviderCheckout (amount, what it's for, return URLs), so
|
||||
* the payment that comes back can be matched and checked, and so the payer can be sent to
|
||||
* /pay/<id>: there they choose a provider when several are enabled, or their browser posts a
|
||||
* provider's form. Admins switch providers on and off and set their order in Site settings →
|
||||
* Payments (AppSetting `payment_providers`). Provider fees are never deducted or added:
|
||||
* payments are recorded at the full amount and fees are handled at cashup.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { getSetting } = require('../utils/settingsCache');
|
||||
|
||||
const PROVIDERS = [require('./providers/yoco')];
|
||||
const PROVIDERS = [require('./providers/yoco'), require('./providers/payfast')];
|
||||
const BY_ID = new Map(PROVIDERS.map(p => [p.id, p]));
|
||||
|
||||
// One minimum for every provider: online payments must be at least R15.
|
||||
@@ -77,35 +85,94 @@ function serializeProviderSettings(list) {
|
||||
return JSON.stringify({ order: ids, enabled: Object.fromEntries(list.filter(p => getProvider(p.id)).map(p => [p.id, p.enabled === true])) });
|
||||
}
|
||||
|
||||
/** The payer's page for a checkout: /pay/<id> on the frontend. */
|
||||
function payPageUrl(checkoutId) {
|
||||
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3000').split(',')[0].trim().replace(/\/$/, '');
|
||||
return `${base}/pay/${encodeURIComponent(checkoutId)}`;
|
||||
}
|
||||
|
||||
/** Throws unless `provider` may take payments now (enabled; and set up, or it explains). */
|
||||
async function assertUsable(provider) {
|
||||
const state = (await providerSettings()).find(p => p.id === provider.id);
|
||||
if (!state?.enabled) throw paymentError(`Paying with ${provider.label} is turned off.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a checkout with `providerId` (or the first available provider).
|
||||
* @returns {Promise<{ provider: string, checkoutId: string, redirectUrl: string }>}
|
||||
* Starts a recorded checkout with a provider. Idempotent for a provider that was already
|
||||
* started with a redirect (Yoco); form providers rebuild their (deterministic) form.
|
||||
* @returns {Promise<{ provider, redirectUrl, form? }>}
|
||||
*/
|
||||
async function startCheckout(checkout, provider) {
|
||||
if (checkout.provider === provider.id && checkout.redirectUrl) return { provider: provider.id, redirectUrl: checkout.redirectUrl };
|
||||
const request = checkout.request || {};
|
||||
const started = await provider.createCheckout({
|
||||
...request,
|
||||
checkoutId: checkout.id,
|
||||
amountCents: checkout.amountCents,
|
||||
currency: checkout.currency,
|
||||
metadata: checkout.metadata,
|
||||
});
|
||||
if (started.form) {
|
||||
if (checkout.provider !== provider.id) await prisma.providerCheckout.update({ where: { id: checkout.id }, data: { provider: provider.id } });
|
||||
return { provider: provider.id, redirectUrl: payPageUrl(checkout.id), form: started.form };
|
||||
}
|
||||
await prisma.providerCheckout.update({
|
||||
where: { id: checkout.id },
|
||||
data: { provider: provider.id, externalId: started.externalId || null, redirectUrl: started.redirectUrl },
|
||||
});
|
||||
return { provider: provider.id, redirectUrl: started.redirectUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a checkout with `providerId`, or the only available provider; with several
|
||||
* available, the payer chooses on /pay/<id>.
|
||||
* @param {object} input - { amountCents, metadata, lineItems, successUrl, cancelUrl, failureUrl,
|
||||
* payer?: { name, email }, description?, idempotencyKey? }
|
||||
* @returns {Promise<{ checkoutId, redirectUrl, provider: string|null }>}
|
||||
*/
|
||||
async function createCheckout(providerId, input) {
|
||||
if (!(input.amountCents >= MIN_ONLINE_AMOUNT * 100)) {
|
||||
throw paymentError(`Online payments must be at least R${MIN_ONLINE_AMOUNT}. Please pay at the door or contact the organiser.`);
|
||||
}
|
||||
let provider = null;
|
||||
const available = await availableProviders();
|
||||
let provider;
|
||||
if (providerId) {
|
||||
provider = getProvider(providerId);
|
||||
if (!provider) throw paymentError('Unknown payment provider.');
|
||||
const state = (await providerSettings()).find(p => p.id === provider.id);
|
||||
if (!state?.enabled) throw paymentError(`Paying with ${provider.label} is turned off.`);
|
||||
} else {
|
||||
provider = available.length ? getProvider(available[0].id) : null;
|
||||
if (!provider) {
|
||||
// Nothing enabled: say so. Enabled but not configured: the provider explains what's missing.
|
||||
const enabled = (await providerSettings()).find(p => p.enabled);
|
||||
if (!enabled) throw paymentError('Online payments are turned off. Please pay at the door or contact the organiser.', 503);
|
||||
provider = getProvider(enabled.id);
|
||||
}
|
||||
await assertUsable(provider);
|
||||
} else if (available.length === 1) {
|
||||
provider = getProvider(available[0].id);
|
||||
} else if (available.length === 0) {
|
||||
// Nothing enabled: say so. Enabled but not set up: the provider explains what's missing.
|
||||
const enabled = (await providerSettings()).find(p => p.enabled);
|
||||
if (!enabled) throw paymentError('Online payments are turned off. Please pay at the door or contact the organiser.', 503);
|
||||
provider = getProvider(enabled.id);
|
||||
}
|
||||
const { checkoutId, redirectUrl } = await provider.createCheckout({ currency: 'ZAR', ...input });
|
||||
return { provider: provider.id, checkoutId, redirectUrl };
|
||||
|
||||
const { amountCents, metadata, currency, ...request } = input;
|
||||
const checkout = await prisma.providerCheckout.create({
|
||||
data: { amountCents, currency: currency || 'ZAR', metadata: metadata || {}, request },
|
||||
});
|
||||
if (!provider) return { checkoutId: checkout.id, redirectUrl: payPageUrl(checkout.id), provider: null };
|
||||
try {
|
||||
const started = await startCheckout(checkout, provider);
|
||||
return { checkoutId: checkout.id, redirectUrl: started.redirectUrl, provider: started.provider };
|
||||
} catch (e) {
|
||||
await prisma.providerCheckout.delete({ where: { id: checkout.id } }).catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** The checkout a webhook is about: by our id, or by the provider's own checkout id. */
|
||||
async function findCheckout(providerId, ref) {
|
||||
if (!ref) return null;
|
||||
const ours = await prisma.providerCheckout.findUnique({ where: { id: String(ref) } }).catch(() => null);
|
||||
if (ours) return ours;
|
||||
return prisma.providerCheckout.findFirst({ where: { provider: providerId, externalId: String(ref) } });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROVIDERS, MIN_ONLINE_AMOUNT, SETTINGS_KEY, paymentError,
|
||||
getProvider, parseProviderSettings, serializeProviderSettings, providerSettings, availableProviders, createCheckout,
|
||||
getProvider, parseProviderSettings, serializeProviderSettings, providerSettings, availableProviders,
|
||||
payPageUrl, assertUsable, createCheckout, startCheckout, findCheckout,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* PayFast as a payment provider (see ../index.js for the interface), using PayFast's
|
||||
* "custom integration" (developers.payfast.co.za/docs → Custom Integration).
|
||||
*
|
||||
* Checkout: the payer's browser posts a signed form to https://<host>/eng/process (from our
|
||||
* /pay/<checkoutId> page). Fields go in the order PayFast lists them; the signature is the MD5
|
||||
* of the non-blank fields as name=value pairs (URL-encoded, upper-case escapes, spaces as '+')
|
||||
* joined with '&', plus &passphrase=… when one is set.
|
||||
*
|
||||
* ITN (Instant Transaction Notification): PayFast posts the result, form-encoded, to
|
||||
* /api/webhooks/payfast. Before trusting it we check, as PayFast requires: the signature, that
|
||||
* it came from a PayFast host, and — by posting it back to /eng/query/validate — that PayFast
|
||||
* says it's VALID. The amount is checked against the checkout in ../webhook.js.
|
||||
*
|
||||
* Status checks: when an ITN never arrives, findPayments looks the checkout up in PayFast's
|
||||
* transaction history (its API, signed differently — see apiSignature). Refunds aren't done
|
||||
* through the API: they're made in the PayFast dashboard and recorded with the manual flow.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const dns = require('dns').promises;
|
||||
const axios = require('axios');
|
||||
const { getPayfastConfig, payfastHost, payfastNotifyUrl } = require('../../utils/payfast');
|
||||
|
||||
// getTransaction: payments can be looked up in PayFast's transaction history (findPayments).
|
||||
const capabilities = { refund: false, getTransaction: true, listTransactions: false };
|
||||
|
||||
// Hosts PayFast sends ITNs from.
|
||||
const VALID_HOSTS = ['www.payfast.co.za', 'w1w.payfast.co.za', 'w2w.payfast.co.za', 'sandbox.payfast.co.za'];
|
||||
|
||||
async function isConfigured() {
|
||||
const c = await getPayfastConfig();
|
||||
return !!(c.merchantId && c.merchantKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-encodes a value the way PayFast's signature expects (PHP urlencode): every character
|
||||
* but letters, digits and -_. is %-escaped in upper case, and spaces become '+'. Checked
|
||||
* against the sandbox: PayFast's own Node sample, which leaves ' ( ) ! * ~ as they are, is
|
||||
* rejected for names like "Men's Camp (Youth)"; this is accepted.
|
||||
*/
|
||||
function pfEncode(value) {
|
||||
return encodeURIComponent(String(value).trim())
|
||||
.replace(/[!'()*~]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
|
||||
.replace(/%20/g, '+');
|
||||
}
|
||||
|
||||
/**
|
||||
* name=value&… of the pairs, in order. Pure. A checkout signs only the non-blank fields; an
|
||||
* ITN is checked over every field PayFast posted, blanks included (`allFields`).
|
||||
*/
|
||||
function paramString(pairs, { allFields = false } = {}) {
|
||||
const keep = allFields ? pairs : pairs.filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '');
|
||||
return keep.map(([k, v]) => `${k}=${pfEncode(v ?? '')}`).join('&');
|
||||
}
|
||||
|
||||
/** PayFast's MD5 signature over ordered pairs (and the passphrase, if any). Pure. */
|
||||
function signature(pairs, passphrase = '', opts = {}) {
|
||||
let s = paramString(pairs, opts);
|
||||
if (passphrase) s += `&passphrase=${pfEncode(passphrase)}`;
|
||||
return crypto.createHash('md5').update(s).digest('hex');
|
||||
}
|
||||
|
||||
/** Text we send PayFast (item and payer names): one line, within PayFast's length limit. Pure. */
|
||||
function cleanText(value, max) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, max);
|
||||
}
|
||||
|
||||
/** "Camp ticket x2, T-shirt" → the item description (charged lines only). Pure. */
|
||||
function describeLines(lineItems) {
|
||||
return (lineItems || []).filter(l => l.unitCents > 0).map(l => `${l.name}${l.quantity > 1 ? ` x${l.quantity}` : ''}`).join(', ');
|
||||
}
|
||||
|
||||
async function createCheckout({ checkoutId, amountCents, successUrl, cancelUrl, payer, description, lineItems }) {
|
||||
const c = await getPayfastConfig();
|
||||
if (!c.merchantId || !c.merchantKey) {
|
||||
const err = new Error('PayFast isn\'t set up. An admin needs to add the merchant ID and key in Site Settings → Payments → PayFast.');
|
||||
err.statusCode = 503;
|
||||
throw err;
|
||||
}
|
||||
const notifyUrl = payfastNotifyUrl();
|
||||
const [first, ...rest] = String(payer?.name || '').trim().split(/\s+/);
|
||||
const email = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(String(payer?.email || '')) && !String(payer.email).endsWith('.local') && !String(payer.email).endsWith('.invalid') ? payer.email : '';
|
||||
const items = describeLines(lineItems);
|
||||
// In the order PayFast documents them — the signature depends on it.
|
||||
const pairs = [
|
||||
['merchant_id', c.merchantId],
|
||||
['merchant_key', c.merchantKey],
|
||||
['return_url', successUrl],
|
||||
['cancel_url', cancelUrl],
|
||||
['notify_url', notifyUrl],
|
||||
['name_first', cleanText(first, 100)],
|
||||
['name_last', cleanText(rest.join(' '), 100)],
|
||||
['email_address', email],
|
||||
['m_payment_id', checkoutId],
|
||||
['amount', (amountCents / 100).toFixed(2)],
|
||||
['item_name', cleanText(description || items || 'Payment', 100)],
|
||||
['item_description', cleanText(items, 255)],
|
||||
].filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '');
|
||||
const fields = Object.fromEntries(pairs.map(([k, v]) => [k, String(v).trim()]));
|
||||
fields.signature = signature(pairs, c.passphrase);
|
||||
return { form: { action: `https://${payfastHost(c.mode)}/eng/process`, fields } };
|
||||
}
|
||||
|
||||
/** The ITN's name/value pairs, decoded, in the order PayFast sent them. Pure. */
|
||||
function parsePairs(rawBody) {
|
||||
return String(rawBody || '').split('&').filter(Boolean).map(kv => {
|
||||
const i = kv.indexOf('=');
|
||||
const dec = (s) => decodeURIComponent(s.replace(/\+/g, ' '));
|
||||
return i < 0 ? [dec(kv), ''] : [dec(kv.slice(0, i)), dec(kv.slice(i + 1))];
|
||||
});
|
||||
}
|
||||
|
||||
function readWebhookBody(rawBody) {
|
||||
return Object.fromEntries(parsePairs(rawBody));
|
||||
}
|
||||
|
||||
/** The caller's IP, without IPv4-mapped-IPv6 noise (needs TRUST_PROXY behind a proxy). */
|
||||
function requestIp(req) {
|
||||
return String(req.ip || req.socket?.remoteAddress || '').replace(/^::ffff:/, '');
|
||||
}
|
||||
|
||||
async function payfastIps() {
|
||||
const all = await Promise.all(VALID_HOSTS.map(h => dns.lookup(h, { all: true }).catch(() => [])));
|
||||
return new Set(all.flat().map(a => a.address));
|
||||
}
|
||||
|
||||
/** PayFast's four ITN checks, less the amount (checked against the checkout in ../webhook.js). */
|
||||
async function verifyWebhook(req, { lookupIps = payfastIps, post = axios.post } = {}) {
|
||||
const pairs = parsePairs(req.rawBody);
|
||||
const data = Object.fromEntries(pairs);
|
||||
if (!data.signature || !data.pf_payment_id) return { ok: false, status: 400, message: 'Not a PayFast notification' };
|
||||
|
||||
const c = await getPayfastConfig();
|
||||
if (!c.merchantId) return { ok: false, status: 500, message: 'PayFast isn\'t set up' };
|
||||
if (data.merchant_id && data.merchant_id !== c.merchantId) return { ok: false, status: 403, message: 'Notification for a different PayFast merchant' };
|
||||
|
||||
// 1. Signature: every posted field before the signature, in order, plus our passphrase.
|
||||
const signed = [];
|
||||
for (const pair of pairs) { if (pair[0] === 'signature') break; signed.push(pair); }
|
||||
if (signature(signed, c.passphrase, { allFields: true }) !== data.signature) {
|
||||
console.error('PayFast ITN: invalid signature');
|
||||
return { ok: false, status: 403, message: 'Invalid signature' };
|
||||
}
|
||||
|
||||
// 2. It came from PayFast.
|
||||
if (process.env.PAYFAST_SKIP_IP_CHECK !== 'true') {
|
||||
const ip = requestIp(req);
|
||||
if (!(await lookupIps()).has(ip)) {
|
||||
console.error(`PayFast ITN from ${ip}, which isn't a PayFast address (behind a proxy? set TRUST_PROXY)`);
|
||||
return { ok: false, status: 403, message: 'Notification did not come from PayFast' };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. PayFast confirms the details.
|
||||
let reply = '';
|
||||
try {
|
||||
const res = await post(`https://${payfastHost(c.mode)}/eng/query/validate`, paramString(signed, { allFields: true }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 15000 });
|
||||
reply = String(res.data || '').trim();
|
||||
} catch (e) {
|
||||
console.error('PayFast ITN: could not confirm with PayFast:', e.message);
|
||||
return { ok: false, status: 502, message: 'Could not confirm the notification with PayFast' };
|
||||
}
|
||||
if (reply !== 'VALID') {
|
||||
console.error('PayFast ITN: PayFast did not confirm it:', reply.slice(0, 100));
|
||||
return { ok: false, status: 403, message: 'PayFast did not confirm the notification' };
|
||||
}
|
||||
|
||||
return { ok: true, webhookId: data.pf_payment_id, timestamp: null };
|
||||
}
|
||||
|
||||
/** An ITN body → { type, transaction }. Pure. */
|
||||
function parseWebhook(body) {
|
||||
const status = String(body?.payment_status || '').toUpperCase();
|
||||
const gross = parseFloat(body?.amount_gross);
|
||||
return {
|
||||
type: status === 'COMPLETE' ? 'payment.succeeded' : `payment.${status.toLowerCase() || 'unknown'}`,
|
||||
transaction: {
|
||||
externalId: body?.pf_payment_id || null,
|
||||
amountCents: Number.isFinite(gross) ? Math.round(gross * 100) : null,
|
||||
currency: 'ZAR',
|
||||
createdDate: null,
|
||||
checkoutId: body?.m_payment_id || null,
|
||||
// PayFast doesn't say how the payer paid; it settles like a card payment.
|
||||
methodType: null,
|
||||
metadata: {},
|
||||
status: status === 'COMPLETE' ? 'succeeded' : status.toLowerCase(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── PayFast API (developers.payfast.co.za/api) ──────────────────────────────
|
||||
|
||||
const API_BASE = 'https://api.payfast.co.za';
|
||||
|
||||
/**
|
||||
* The API signature: every header and query variable plus the passphrase, sorted
|
||||
* alphabetically, URL-encoded, joined with '&' (not the payment form's field order). Pure.
|
||||
*/
|
||||
function apiSignature(vars) {
|
||||
const s = Object.keys(vars).sort()
|
||||
.filter(k => vars[k] !== undefined && vars[k] !== null && String(vars[k]) !== '')
|
||||
.map(k => `${k}=${pfEncode(vars[k])}`).join('&');
|
||||
return crypto.createHash('md5').update(s).digest('hex');
|
||||
}
|
||||
|
||||
/** A signed GET to the PayFast API; the sandbox adds ?testing=true (left out of the signature). */
|
||||
async function apiGet(path, query = {}, { get = axios.get } = {}) {
|
||||
const c = await getPayfastConfig();
|
||||
const headers = { 'merchant-id': c.merchantId, version: 'v1', timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, '+00:00') };
|
||||
const signature = apiSignature({ ...headers, ...query, ...(c.passphrase ? { passphrase: c.passphrase } : {}) });
|
||||
const params = { ...query, ...(c.mode === 'live' ? {} : { testing: 'true' }) };
|
||||
try {
|
||||
const res = await get(`${API_BASE}${path}`, { params, headers: { ...headers, signature }, timeout: 20000, responseType: 'text', transformResponse: r => r });
|
||||
return res.data;
|
||||
} catch (e) {
|
||||
const status = e.response?.status;
|
||||
let detail = '';
|
||||
try { detail = JSON.parse(e.response?.data || '{}')?.data?.response || ''; } catch { /* not JSON */ }
|
||||
const err = new Error(status === 401
|
||||
? 'PayFast refused the merchant ID or passphrase (check they match your PayFast dashboard, and the mode).'
|
||||
: `Could not reach PayFast${detail ? `: ${detail}` : status ? ` (${status})` : `: ${e.message}`}`);
|
||||
err.statusCode = status === 401 ? 400 : 502;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks the credentials with PayFast (GET /ping, signed). */
|
||||
async function testConnection(deps) {
|
||||
await apiGet('/ping', {}, deps);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Rows of a CSV with a header line, as objects keyed by the header. Pure. */
|
||||
function parseCsv(text) {
|
||||
const rows = [];
|
||||
let row = [], field = '', quoted = false;
|
||||
const s = String(text || '');
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (quoted) {
|
||||
if (ch === '"' && s[i + 1] === '"') { field += '"'; i++; }
|
||||
else if (ch === '"') quoted = false;
|
||||
else field += ch;
|
||||
} else if (ch === '"') quoted = true;
|
||||
else if (ch === ',') { row.push(field); field = ''; }
|
||||
else if (ch === '\n' || ch === '\r') {
|
||||
if (ch === '\r' && s[i + 1] === '\n') i++;
|
||||
row.push(field); field = '';
|
||||
if (row.some(v => v !== '')) rows.push(row);
|
||||
row = [];
|
||||
} else field += ch;
|
||||
}
|
||||
row.push(field);
|
||||
if (row.some(v => v !== '')) rows.push(row);
|
||||
const [header, ...data] = rows;
|
||||
return header ? data.map(r => Object.fromEntries(header.map((h, i) => [h, r[i] ?? '']))) : [];
|
||||
}
|
||||
|
||||
const day = (d) => new Date(d).toISOString().slice(0, 10);
|
||||
|
||||
/**
|
||||
* Payments PayFast received for our checkouts (from its transaction history, matched on
|
||||
* "M Payment ID" = our checkout id). For notifications (ITNs) that never arrived.
|
||||
* @param {object[]} checkouts - ProviderCheckout rows
|
||||
* @returns {Promise<Map<string, object>>} checkout id → transaction (as parseWebhook gives)
|
||||
*/
|
||||
async function findPayments(checkouts, deps) {
|
||||
const found = new Map();
|
||||
if (!checkouts.length) return found;
|
||||
const ids = new Set(checkouts.map(c => c.id));
|
||||
const from = day(Math.min(...checkouts.map(c => new Date(c.createdAt).getTime())) - 24 * 60 * 60 * 1000);
|
||||
const to = day(Date.now() + 24 * 60 * 60 * 1000);
|
||||
const PAGE = 1000;
|
||||
for (let offset = 0; offset < 20 * PAGE; offset += PAGE) {
|
||||
const rows = parseCsv(await apiGet('/transactions/history', { from, to, limit: PAGE, offset }, deps));
|
||||
for (const r of rows) {
|
||||
const id = r['M Payment ID'];
|
||||
if (!ids.has(id) || r.Type !== 'FUNDS_RECEIVED' || found.has(id)) continue;
|
||||
const gross = parseFloat(String(r.Gross).replace(/,/g, ''));
|
||||
found.set(id, {
|
||||
externalId: r['PF Payment ID'] || null,
|
||||
amountCents: Number.isFinite(gross) ? Math.round(gross * 100) : null,
|
||||
currency: r.Currency || 'ZAR',
|
||||
// PayFast's history times are South African time
|
||||
createdDate: r.Date ? new Date(`${r.Date.replace(' ', 'T')}+02:00`) : null,
|
||||
checkoutId: id,
|
||||
methodType: null,
|
||||
metadata: {},
|
||||
status: 'succeeded',
|
||||
});
|
||||
}
|
||||
if (rows.length < PAGE || found.size === ids.size) break;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
id: 'payfast', label: 'PayFast', capabilities,
|
||||
isConfigured, createCheckout, verifyWebhook, parseWebhook, readWebhookBody, findPayments, testConnection,
|
||||
// for tests
|
||||
pfEncode, paramString, signature, cleanText, parsePairs, VALID_HOSTS, apiSignature, parseCsv,
|
||||
};
|
||||
@@ -30,7 +30,7 @@ async function createCheckout({ amountCents, currency, metadata, lineItems, succ
|
||||
const data = await yocoRequest('post', '/checkouts', {
|
||||
amount: amountCents, currency, metadata, successUrl, cancelUrl, failureUrl, lineItems: toYocoLineItems(lineItems),
|
||||
}, undefined, idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {});
|
||||
return { checkoutId: data.id, redirectUrl: data.redirectUrl };
|
||||
return { externalId: data.id, redirectUrl: data.redirectUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Payment status checks — for when a provider's notification (webhook / ITN) never arrives.
|
||||
*
|
||||
* An open checkout is one we started with a provider that has no payment recorded yet. A
|
||||
* provider that can look payments up (capabilities.getTransaction, findPayments) is asked
|
||||
* whether it received one; anything it found is settled exactly as its webhook would have been
|
||||
* (same amount check, same recording, same emails). Run every 15 minutes by the
|
||||
* 'payment-status' job for recent checkouts, and on demand from the Payments page.
|
||||
* Providers that can't be asked (Yoco: its API has no lookup) show as "can't check" — their
|
||||
* payments are found in the provider's portal and recorded with Reconcile.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { getProvider } = require('./index');
|
||||
const { settlePayment } = require('./webhook');
|
||||
|
||||
/** Whether a checkout has been paid: a payment came back for it, or its registration is paid. */
|
||||
async function checkoutPaid(checkout) {
|
||||
const refs = [checkout.id, checkout.externalId].filter(Boolean);
|
||||
const tx = await prisma.providerTransaction.findFirst({ where: { checkoutId: { in: refs }, paymentId: { not: null } }, select: { id: true } });
|
||||
if (tx) return true;
|
||||
const registrationId = checkout.metadata?.registrationId;
|
||||
if (!registrationId) return false;
|
||||
const reg = await prisma.registration.findUnique({ where: { id: String(registrationId) }, select: { status: true } });
|
||||
return reg?.status === 'paid';
|
||||
}
|
||||
|
||||
/** A payment held for review on this checkout (paid a different amount), if any. */
|
||||
async function heldForReview(checkout) {
|
||||
const refs = [checkout.id, checkout.externalId].filter(Boolean);
|
||||
return prisma.providerTransaction.findFirst({ where: { checkoutId: { in: refs }, paymentId: null, ignored: false, note: { not: null } }, select: { id: true, note: true } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks each checkout's provider whether it was paid and records what it finds.
|
||||
* @returns {Promise<Map<string, { status: 'paid'|'recorded'|'needs_review'|'not_paid'|'cant_check', note? }>>}
|
||||
*/
|
||||
async function checkCheckouts(checkouts) {
|
||||
const out = new Map();
|
||||
const byProvider = new Map();
|
||||
for (const c of checkouts) {
|
||||
if (await checkoutPaid(c)) { out.set(c.id, { status: 'paid' }); continue; }
|
||||
const provider = getProvider(c.provider);
|
||||
if (!provider?.findPayments) { out.set(c.id, { status: 'cant_check' }); continue; }
|
||||
if (!byProvider.has(provider.id)) byProvider.set(provider.id, []);
|
||||
byProvider.get(provider.id).push(c);
|
||||
}
|
||||
for (const [providerId, list] of byProvider) {
|
||||
const provider = getProvider(providerId);
|
||||
const found = await provider.findPayments(list);
|
||||
for (const c of list) {
|
||||
const tx = found.get(c.id);
|
||||
if (!tx) {
|
||||
const held = await heldForReview(c);
|
||||
out.set(c.id, held ? { status: 'needs_review', note: held.note } : { status: 'not_paid' });
|
||||
continue;
|
||||
}
|
||||
const event = {
|
||||
type: 'payment.succeeded',
|
||||
transaction: { ...tx, metadata: { ...(c.metadata || {}), ...(tx.metadata || {}) } },
|
||||
checkoutRefs: [c.id, c.externalId].filter(Boolean),
|
||||
};
|
||||
const settled = await settlePayment(provider, event, { source: 'status-check', provider: providerId, transaction: tx }, c);
|
||||
out.set(c.id, settled.needsReview ? { status: 'needs_review', note: settled.note } : { status: 'recorded' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Recent checkouts started with a provider and not paid yet, newest first. */
|
||||
async function openCheckouts({ since, until = new Date() } = {}) {
|
||||
const rows = await prisma.providerCheckout.findMany({
|
||||
where: { provider: { not: null }, createdAt: { gte: since, lte: until } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
const open = [];
|
||||
for (const c of rows) if (!(await checkoutPaid(c))) open.push(c);
|
||||
return open;
|
||||
}
|
||||
|
||||
/**
|
||||
* Background job: checks checkouts started between 3 days and 10 minutes ago (giving the
|
||||
* webhook time to arrive first) with providers that can be asked.
|
||||
*/
|
||||
async function runPaymentStatusJob(now = new Date()) {
|
||||
const since = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
|
||||
const until = new Date(now.getTime() - 10 * 60 * 1000);
|
||||
const checkable = (await openCheckouts({ since, until })).filter(c => getProvider(c.provider)?.findPayments);
|
||||
if (!checkable.length) return { checked: 0, recorded: 0, needsReview: 0 };
|
||||
const results = [...(await checkCheckouts(checkable)).values()];
|
||||
return {
|
||||
checked: checkable.length,
|
||||
recorded: results.filter(r => r.status === 'recorded').length,
|
||||
needsReview: results.filter(r => r.status === 'needs_review').length,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { checkoutPaid, checkCheckouts, openCheckouts, runPaymentStatusJob };
|
||||
@@ -5,6 +5,9 @@
|
||||
* - Every event is stored as a ProviderTransaction (by provider + external id, so repeats
|
||||
* are idempotent). Events that aren't for one of our checkouts stay unreconciled for the
|
||||
* Payments page.
|
||||
* - The event is matched to the checkout we started (ProviderCheckout, by our id or the
|
||||
* provider's): that gives who/what it's for, and the amount we asked for. A payment of a
|
||||
* different amount isn't recorded — it waits on the Payments page with a note.
|
||||
* - payment.succeeded for a registration or donation records a Payment at the full amount
|
||||
* paid (provider fees are dealt with at cashup), splits off any donation added at checkout,
|
||||
* updates the registration's status and generates tickets once it's paid, then emails.
|
||||
@@ -14,7 +17,7 @@
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { getProvider } = require('./index');
|
||||
const { getProvider, findCheckout } = require('./index');
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
|
||||
|
||||
@@ -29,11 +32,17 @@ const handleProviderWebhook = (providerId) => async (req, res) => {
|
||||
return res.status(verified.status).json({ success: false, message: verified.message, ...(verified.details ? { details: verified.details } : {}) });
|
||||
}
|
||||
|
||||
const body = JSON.parse(req.rawBody);
|
||||
const body = provider.readWebhookBody ? provider.readWebhookBody(req.rawBody) : JSON.parse(req.rawBody);
|
||||
const event = provider.parseWebhook(body);
|
||||
const tx = event.transaction;
|
||||
const receivedCheckoutId = tx.checkoutId;
|
||||
|
||||
// The checkout we started: its metadata says who/what this is for (PayFast sends none back)
|
||||
const checkout = await findCheckout(provider.id, tx.checkoutId).catch(() => null);
|
||||
if (checkout) tx.metadata = { ...(checkout.metadata || {}), ...(tx.metadata || {}) };
|
||||
// A registration remembers our checkout id (older ones: the provider's)
|
||||
event.checkoutRefs = [...new Set([checkout?.id, tx.checkoutId].filter(Boolean))];
|
||||
|
||||
// Optional filtering: only process specific checkoutIds or prefixes on this endpoint
|
||||
try {
|
||||
const idsEnv = process.env.YOCO_ALLOWED_CHECKOUT_IDS || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_IDS || '';
|
||||
@@ -58,7 +67,7 @@ const handleProviderWebhook = (providerId) => async (req, res) => {
|
||||
// Not one of our registration checkouts and not a donation → keep it for reconciling
|
||||
try {
|
||||
if (receivedCheckoutId) {
|
||||
const existingRegistration = await prisma.registration.findFirst({ where: { checkoutId: receivedCheckoutId }, select: { id: true } });
|
||||
const existingRegistration = await prisma.registration.findFirst({ where: { checkoutId: { in: event.checkoutRefs } }, select: { id: true } });
|
||||
if (!existingRegistration) {
|
||||
const meta = tx.metadata || {};
|
||||
const isDonation = meta.type === 'donation' || !!meta.isDonation || !!meta.eventId;
|
||||
@@ -86,7 +95,11 @@ const handleProviderWebhook = (providerId) => async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const processedData = await recordSucceededPayment(provider.id, event, body);
|
||||
const settled = await settlePayment(provider, event, body, checkout);
|
||||
if (settled.needsReview) {
|
||||
return res.status(200).json({ success: true, message: 'Webhook acknowledged; amount differs from the checkout, so it needs review', data: { needsReview: true } });
|
||||
}
|
||||
const processedData = settled.processedData;
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: `Successfully processed ${event.type} webhook`,
|
||||
@@ -98,7 +111,23 @@ const handleProviderWebhook = (providerId) => async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
const REG_INCLUDE = { event: { select: { id: true, title: true } }, user: { select: { id: true, name: true, email: true } } };
|
||||
/**
|
||||
* A successful payment for one of our checkouts (from a webhook, or found by a status check):
|
||||
* held for review with a note when the amount differs from the checkout, otherwise recorded.
|
||||
* @returns {Promise<{ needsReview: true, note } | { needsReview: false, processedData }>}
|
||||
*/
|
||||
async function settlePayment(provider, event, body, checkout) {
|
||||
const tx = event.transaction;
|
||||
if (checkout && tx.amountCents !== checkout.amountCents) {
|
||||
const note = `Paid R${((tx.amountCents || 0) / 100).toFixed(2)}, but the checkout was for R${(checkout.amountCents / 100).toFixed(2)}. Check it with ${provider.label} before recording it.`;
|
||||
console.warn(`[${provider.id}] amount mismatch on checkout ${checkout.id}: ${note}`);
|
||||
try { await saveProviderTransaction(provider.id, event, body, { note }); } catch (e) { console.warn('Failed to store mismatched payment:', e?.message || e); }
|
||||
return { needsReview: true, note };
|
||||
}
|
||||
return { needsReview: false, processedData: await recordSucceededPayment(provider.id, event, body) };
|
||||
}
|
||||
|
||||
const REG_INCLUDE ={ event: { select: { id: true, title: true } }, user: { select: { id: true, name: true, email: true } } };
|
||||
|
||||
/** Records a successful payment from a webhook. Idempotent on the provider's payment id. */
|
||||
async function recordSucceededPayment(providerId, event, body) {
|
||||
@@ -113,7 +142,8 @@ async function recordSucceededPayment(providerId, event, body) {
|
||||
}
|
||||
|
||||
let registration = null;
|
||||
if (checkoutId) registration = await prisma.registration.findFirst({ where: { checkoutId }, include: REG_INCLUDE });
|
||||
const refs = event.checkoutRefs || [checkoutId].filter(Boolean);
|
||||
if (refs.length) registration = await prisma.registration.findFirst({ where: { checkoutId: { in: refs } }, include: REG_INCLUDE });
|
||||
if (!registration && metadata.registrationId) registration = await prisma.registration.findUnique({ where: { id: metadata.registrationId }, include: REG_INCLUDE });
|
||||
|
||||
// Resolve userId: registration owner → metadata → first active admin (never a non-existent UUID)
|
||||
@@ -268,4 +298,4 @@ async function saveProviderTransaction(providerId, event, body, updates = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleProviderWebhook, recordSucceededPayment, updateRegistrationStatus, saveProviderTransaction };
|
||||
module.exports = { handleProviderWebhook, settlePayment, recordSucceededPayment, updateRegistrationStatus, saveProviderTransaction };
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,10 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getConfig, updateConfig, testConnection } = require('../controllers/payfastController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/config', protect, admin, getConfig);
|
||||
router.put('/config', protect, admin, updateConfig);
|
||||
router.post('/test', protect, admin, testConnection);
|
||||
|
||||
module.exports = router;
|
||||
@@ -15,30 +15,40 @@ const {
|
||||
getPaymentStats,
|
||||
sendReceipt,
|
||||
} = require('../controllers/paymentController');
|
||||
const { listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings } = require('../controllers/paymentProvidersController');
|
||||
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, 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;
|
||||
@@ -0,0 +1,85 @@
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const {
|
||||
getMyPrivacy,
|
||||
updateMyNews,
|
||||
exportMyData,
|
||||
createMyRequest,
|
||||
getUnsubscribe,
|
||||
postUnsubscribe,
|
||||
oneClickUnsubscribe,
|
||||
oneClickRedirect,
|
||||
listRequests,
|
||||
completeRequest,
|
||||
rejectRequest,
|
||||
} = require('../controllers/privacyController');
|
||||
const {
|
||||
getNotice,
|
||||
acceptMyNotice,
|
||||
getNoticeDraft,
|
||||
saveNoticeDraft,
|
||||
discardNoticeDraft,
|
||||
publishNotice,
|
||||
listOperators,
|
||||
createOperator,
|
||||
updateOperator,
|
||||
deleteOperator,
|
||||
consentLog,
|
||||
accessLog,
|
||||
getReadiness,
|
||||
getRetention,
|
||||
saveRetention,
|
||||
runRetentionNow,
|
||||
listBreaches,
|
||||
createBreach,
|
||||
updateBreach,
|
||||
} = require('../controllers/privacyAdminController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
// Exports are heavy-ish (every record the person has) — a few per 10 minutes is plenty.
|
||||
const exportLimiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false });
|
||||
|
||||
// The person themselves
|
||||
router.get('/me', protect, getMyPrivacy);
|
||||
router.put('/me/news', protect, updateMyNews);
|
||||
router.get('/me/export', protect, exportLimiter, exportMyData);
|
||||
router.post('/me/requests', protect, createMyRequest);
|
||||
|
||||
// Unsubscribe links (signed tokens, no login)
|
||||
router.get('/unsubscribe/:token', getUnsubscribe);
|
||||
router.post('/unsubscribe/:token', postUnsubscribe);
|
||||
router.get('/unsubscribe/:token/one-click', oneClickRedirect);
|
||||
router.post('/unsubscribe/:token/one-click', express.urlencoded({ extended: false }), oneClickUnsubscribe);
|
||||
|
||||
// Admin: data requests inbox
|
||||
router.get('/requests', protect, admin, listRequests);
|
||||
router.post('/requests/:id/complete', protect, admin, completeRequest);
|
||||
router.post('/requests/:id/reject', protect, admin, rejectRequest);
|
||||
|
||||
// Privacy notice: public reading, acceptance, and the admin editor
|
||||
router.get('/notice', getNotice);
|
||||
router.post('/me/notice/accept', protect, acceptMyNotice);
|
||||
router.get('/notice/draft', protect, admin, getNoticeDraft);
|
||||
router.put('/notice/draft', protect, admin, saveNoticeDraft);
|
||||
router.delete('/notice/draft', protect, admin, discardNoticeDraft);
|
||||
router.post('/notice/publish', protect, admin, publishNotice);
|
||||
|
||||
// Admin: operator register, consent log, access log
|
||||
router.get('/operators', protect, admin, listOperators);
|
||||
router.post('/operators', protect, admin, createOperator);
|
||||
router.put('/operators/:id', protect, admin, updateOperator);
|
||||
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;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { handleYocoWebhook, handleWhatsappWebhook } = require('../controllers/webhookController');
|
||||
const { handleProviderWebhook } = require('../payments/webhook');
|
||||
|
||||
// Yoco webhook endpoint
|
||||
// Payment provider webhooks (src/payments)
|
||||
router.post('/yoco', handleYocoWebhook);
|
||||
router.post('/payfast', handleProviderWebhook('payfast'));
|
||||
router.post('/whatsapp', handleWhatsappWebhook);
|
||||
|
||||
module.exports = router;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user