Compare commits

..
45 Commits
Author SHA1 Message Date
joshua 992fad37d2 Merge branch 'release/2.0' (2.0.0) 2026-11-02 17:49:34 +02:00
joshuaandClaude Opus 5.5 a4bdf846ad Release 2.0.0
AI helpers, API v1 removed, Argon2id passwords, React Compiler lint
rules back to errors, migration guide. Docs (HANDOVER, roadmap) mark
2.0.0 as released.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:49:30 +02:00
joshuaandClaude Opus 5.5 8cc2cefe80 WIP 2.0.0: React Compiler lint rules back to errors; docs
- The 192 places the rules flagged are fixed and the rules are errors
  again (npx eslint src: nothing). lib/hooks.ts: useStateSync (state
  that follows other state, set while rendering), useExternalEffect
  (API loads and browser reads), useNow; lib/navigation.ts hardNavigate
  for full reloads after sign-out or identity switches; other pages use
  the router. Resets moved into the handlers that cause them.
- The banner shows and hides at its times on open pages.
- HANDOVER, roadmap, README, FEATURES, SETTINGS, CHANGELOG updated;
  2.0.0 section drafted in WHATS-NEW.

e2e 102/102 (PayFast sandbox skipped); production build passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:45:25 +02:00
joshuaandClaude Opus 5.5 9913a13f70 WIP 2.0.0: v1 removed; Argon2id passwords; node-fetch gone
- /api (v1) answers 410 API_V1_REMOVED, except provider webhooks
  (/api/webhooks/*, /api/whatsapp/webhook) and links in sent messages
  (308s, one-click POST). 43 v1 route files and deprecation.ts removed;
  /docs redirects to /docs/v2; API_DOCUMENTATION.md points there.
- Read-only and impersonation lists name v2 paths only. The campus
  scope route test now runs over v2 routes (v2CampusScope.test.ts).
- Passwords hashed with Argon2id (@node-rs/argon2); bcrypt hashes still
  sign in and are re-hashed at the next sign-in.
- e2e on /api/v2 only (v2() helper); Playwright waits on /api/v2.
- MIGRATING-TO-2.md with the full v1 -> v2 table.
- Two documented response shapes corrected.

Backend 690 tests and e2e 102/102 (PayFast sandbox skipped) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:35:06 +02:00
joshua 89a952dedb Merge branch 'main' into release/2.0 (1.52.3)
# Conflicts:
#	CHANGELOG.md
2026-11-02 15:15:08 +02:00
joshua 61ce138447 Merge branch 'fix/read-only-v2' 2026-11-02 15:14:50 +02:00
joshuaandClaude Opus 5.5 b28c8a8876 Fix: licence read-only mode blocks the v2 routes too (1.52.3)
The read-only guard only listed v1 paths, and every screen has used
/api/v2 since 1.51.15, so a suspended, revoked or unlicensed site could
still create events, take registrations and start payments. The v2
successors of each blocked v1 route are blocked now; a test checks each
blocked v2 path exists in the OpenAPI document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:14:50 +02:00
joshua 8a424ac77e Merge branch 'main' into release/2.0 (1.52.2)
# Conflicts:
#	CHANGELOG.md
#	docs/HANDOVER.md
2026-11-02 14:13:12 +02:00
joshua 31aa787b17 Merge branch 'fix/deploy-local-changes' 2026-11-02 14:04:27 +02:00
joshuaandClaude Opus 5.5 51992814d2 Deploy: stop first when files in the install were changed by hand (1.52.2)
A setup wizard run on one server added files to backend/src that the
backend build picked up, so the deploy failed part-way and rolled back.
deploy.sh now lists files git reports as changed or added (ignored files
and the PM2 config don't count) and stops before backing up or checking
out. --check runs only that; ALLOW_LOCAL_CHANGES=1 deploys anyway; the
script also runs from a copy outside the install (the folder you're in).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:03:12 +02:00
joshua a0ff8dfc89 Merge branch 'main' into release/2.0 (1.52.0, 1.52.1)
# Conflicts:
#	CHANGELOG.md
#	docs/HANDOVER.md
2026-11-02 13:45:28 +02:00
joshua 88d9276960 Merge branch 'docs/openapi-complete' 2026-11-02 13:27:05 +02:00
joshuaandClaude Opus 5.5 1ae4c47001 Docs: every v2 route documents its body, answer and parameters (1.52.1)
Most routes answered by a v1 handler showed an empty body and
`data: unknown` in /docs/v2. Each now documents what its handler reads
and answers, and every path and query parameter is described.

- `documented: { body?, response? }` on v1Route/defineRoute, with shared
  shapes in src/v2/docs.ts. Documentation only: not enforced, so nothing
  that works can start failing; outside production a body or answer that
  doesn't fit is logged ("doesn't match its documented schema").
- QUERY_HELP / PATH_HELP describe common parameters; empty bodies say
  "No body needed".
- Declared the funnel's eventIds and webhook deliveries' status/before.
- tests/v2Docs.test.ts keeps the document complete; frontend types
  regenerated, three call sites' types tightened (no payload change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:26:15 +02:00
joshua 3693d5f2c8 Merge branch 'fix/door-tickets-corrections' 2026-11-02 12:24:00 +02:00
joshuaandClaude Opus 5.5 62b10c3db5 Fix: door and manual registrations get their tickets; admin Corrections (1.52.0)
On events with a required attendee form, tickets waited for the forms, but
At the door and Manual registration never asked for them: payment was
recorded and nobody could be checked in.

- The form pops up at the door and in Manual registration; "Fill in later"
  (any staff) issues the tickets once paid and sends the payer a link
  (Registration.formWaivedAt, POST /api/v2/registrations/{id}/form-later).
- One form per person on picked-session bookings; a required form with no
  questions no longer holds tickets back; every caller asks the ticket
  generator (formsOutstanding).
- Kiosk asks for the forms the server says are missing; headings show as text.
- Admin Corrections page and /api/v2/corrections: issue missing tickets,
  pay later (ticketsReleasedAt), make it free (complimentaryDiscount),
  check in or undo after the event.
- Migration 20261102120000_corrections; unit and e2e tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:01:10 +02:00
joshuaandClaude Opus 5.5 2b3b0e94c9 WIP 2.0.0: message drafts use event placeholders only with an event
Found by the first real eval run (Sonnet 5.5 through the local Hub,
19/19 checks): without an event the drafts still used {{event.title}}
and added an event-details block. The instructions now offer those
placeholders only when an event is given; the eval checks for it, and
adds up split themes when counting feedback. Route tests for the five
AI helpers in v2.test.ts; the eval script loads backend/.env.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMeckMWrKPfc1MMyMJF
2026-10-30 18:25:57 +02:00
joshuaandClaude Opus 5.5 83e3aee40c WIP 2.0.0: AI buttons on the five screens, eval set, privacy notice
Describe your event (new event), Improve/Shorter/Fix mistakes (event
description, banner, email and WhatsApp boxes), Draft with AI (email and
WhatsApp attendees), Summarise this report (people-free reports) and
Summarise feedback (per event). Built-in privacy notice text for AI
(2.0.0 entry in BUILT_IN_UPDATES). npm run ai:eval runs the trial cases
through the Hub with fact checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMeckMWrKPfc1MMyMJF
2026-10-30 18:19:46 +02:00
joshuaandClaude Opus 5.5 f77b4f4164 WIP 2.0.0: the five AI helpers' API
src/ai/helpers.ts: instructions, schemas and inputs for draft event,
summarise report (people-free reports only), draft message, summarise
feedback (no names, access-logged) and improve text. Contact details
are swapped for placeholders before sending and restored after; every
helper gets today's date; weekday clashes and break-even over capacity
are checked in code. v2 routes /ai/draft-event and friends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 17:38:16 +02:00
joshuaandClaude Opus 5.5 36cabfee0d Docs: Sonnet 5.5 for every AI helper (Hub 1.6.1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 17:25:16 +02:00
joshuaandClaude Opus 5.5 206a814973 Docs: 2.0 progress, marketing site plan, AI tested
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:58:48 +02:00
joshuaandClaude Opus 5.5 311fe8b6f1 WIP 2.0.0: AI helper plumbing (status, usage, Site settings -> AI)
The site calls AI through the Hub (protocol 3b, Hub 1.6.0): src/ai
works out whether the AI buttons show (licence token first, then the
Hub, cached a minute), runs a helper call with per-helper switches and
a per-person daily limit, logs AiCall rows, and emails admins at 80%.
v2 /ai/status, /ai/usage, /ai/settings; useAiStatus, AiButton, AiTab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:25:36 +02:00
joshua 7ce25bf40b Merge branch 'docs/handover-roadmap-tidy' 2026-10-30 09:00:49 +02:00
joshuaandClaude Opus 5.5 376b93a438 Docs: bring HANDOVER and ROADMAP-v2 up to date after 1.51.17
HANDOVER: version table in order with 1.51.4/1.51.5 added; Next up
renumbered with the 2.0 clean-ups (lint rules, node-fetch, password
hashing); event picker points at fetchManagedEvents; stale lines fixed.
ROADMAP: status rewritten as a dated summary; section 14 marks what's
done and that CI was removed; Haiku prices flagged to check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:00:49 +02:00
joshua 0cd78ab1b7 Merge branch 'fix/forgot-password-enumeration' 2026-10-30 08:51:41 +02:00
joshuaandClaude Opus 5.5 d49aebb1b0 Security: forgot password and sign-in don't reveal accounts (1.51.17)
- Forgot password answers the same (message, status, timing) for every email; the reset link
  is made and sent after the answer. Rate-limited like sign-in.
- Sign-in with an unknown email is refused exactly like a wrong password, after a password
  check against a stand-in hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-30 08:51:41 +02:00
joshuaandClaude Opus 5.5 98e5d05548 WIP 1.51.17: forgot password and sign-in don't reveal accounts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-30 08:47:04 +02:00
joshua 14ece27690 Merge branch 'feat/api-v2-webhooks-links' 2026-10-30 08:34:41 +02:00
joshuaandClaude Opus 5.5 5b50b0ce4f Incoming webhooks and message links on API v2 (1.51.16)
- /api/v2/webhooks/{yoco,payfast,whatsapp}; new provider set-ups use them, the v1 addresses
  keep working with the same handlers.
- Calendar and one-click unsubscribe links point at v2; old links answer 308 (a one-click POST
  to the old address is still handled there).
- /api/webhooks/whatsapp runs the real WhatsApp handler (was an empty stub).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-30 08:34:41 +02:00
joshuaandClaude Opus 5.5 99f16375b9 WIP 1.51.16: incoming webhooks and message links on v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-30 08:30:05 +02:00
joshua a62cac6335 Merge branch 'feat/api-v2-privacy-admin' 2026-10-30 08:23:49 +02:00
joshuaandClaude Opus 5.5 c4fff7c835 Privacy and admin on API v2 (1.51.15)
- v2: my privacy, unsubscribe links, the privacy admin, audit log, jobs, impersonation,
  settings and first-run set-up, the Hub, outbound webhooks, backups, Yoco/PayFast settings.
- Every call the app makes is now on /api/v2 (incoming webhooks and message links: 1.51.16).
- v1Route handles res.download/sendFile; impersonation end/log allowed on v2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-30 08:23:49 +02:00
joshuaandClaude Opus 5.5 e310743b92 WIP 1.51.15: privacy and admin on v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-30 08:19:24 +02:00
joshua b6c13de5dc Merge branch 'feat/api-v2-people-messaging' 2026-10-30 08:11:48 +02:00
joshuaandClaude Opus 5.5 2a5b12db20 People and messaging on API v2 (1.51.14)
- v2: the rest of accounts (code resend, password reset, activation, sessions, closing,
  activity, two-step sign-in; admin users), /event-listing, messaging and broadcasts,
  scheduled messages, automations, WhatsApp connection, banner, saved filters, dashboards,
  stats, analytics, report files.
- GET /me carries the session fields the app needs (prompts, campus scope, 2FA required).
- defineRoute refuses duplicate routes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-30 08:11:48 +02:00
joshuaandClaude Opus 5.5 8747431bd5 WIP 1.51.14: GET /me carries the session fields the app needs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-30 08:07:06 +02:00
joshuaandClaude Opus 5.5 e95d52ccdd WIP 1.51.14: people and messaging on v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 17:02:26 +02:00
joshua fdc3db8d23 Merge branch 'feat/api-v2-kids-volunteers' 2026-10-29 16:50:11 +02:00
joshuaandClaude Opus 5.5 1a25896999 Kids check-in and volunteers on API v2 (1.51.13)
- v2: my family, kids set-up and the check-in desk (board, families, check-in/out, codes,
  offline), volunteering (roles, sign-up, swaps, self check-in) and rosters (roles, assigning,
  manual check-in, hours).
- Frontend: kids desk, rooms, family, serving and rosters on v2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 16:50:10 +02:00
joshuaandClaude Opus 5.5 97c368c898 WIP 1.51.13: kids check-in and volunteers on v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 16:46:40 +02:00
joshua 94e4de8794 Merge branch 'feat/api-v2-tickets-attendance' 2026-10-29 16:40:28 +02:00
joshuaandClaude Opus 5.5 3a5b042525 Tickets and attendance on API v2 (1.51.12)
- v2: tickets (mine, one, send, issue, transfers, scans), scan locks, the attendance register
  and its export, certificates, allocations.
- Frontend: apiV2Raw() for string-path helpers; every ticket/attendance screen on v2.
- v1Route can run a v1 router by URL; an unanswered chain is a 404.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 16:40:27 +02:00
joshuaandClaude Opus 5.5 a7231aad0c WIP 1.51.12: tickets, scan locks, attendance, allocations on v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 13:52:31 +02:00
joshua 254a5daebd Merge branch 'feat/api-v2-money' 2026-10-29 13:47:49 +02:00
joshuaandClaude Opus 5.5 28e31ffb99 Money on API v2 (1.51.11)
- v2: payments, refunds, donations, payment links and the pay page, providers, cash-ups,
  provider transactions, waitlist, sponsorship, consent, form responses, feedback.
- src/v2/viaV1.ts: v1Route() answers a v2 route with the v1 handler in-process.
- /docs/v2 opens collapsed with a filter (Swagger UI stopped drawing past ~130 endpoints).
- Frontend: every money screen on v2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 13:47:49 +02:00
joshuaandClaude Opus 5.5 6ee1438e23 WIP 1.51.11: money on v2 via v1 handlers (viaV1 adapter)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 13:43:36 +02:00
303 changed files with 91637 additions and 5361 deletions
+317
View File
@@ -7,6 +7,323 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [2.0.0] - 2026-11-02
### Added
- **AI helpers — the plumbing** (2.0, roadmap §15). The site calls AI through the Cross Events Hub
(protocol §3b, Hub 1.6.0), which holds the Claude API key and a monthly allowance in rand per
licence; no key is ever stored on the site. `GET /api/v2/ai/status` says whether to show the AI
buttons: hidden when the licence has no AI (worked out from the licence token, without asking the
Hub), disabled with the reason once the month's allowance is used up or the Hub can't be reached.
The answer is cached for a minute and refreshed by every AI call. `<AiButton>` and
`useAiStatus()` on the frontend; only supervisors and admins see AI.
- **Site settings → AI** (admins): this month's use against the allowance, use per person, per
helper and per day, a switch per helper, and an optional most-uses-per-person-per-day. Admins are
emailed once a month when 80% is used. Changes are audited (`ai_settings_changed`).
- Each AI call is logged in `AiCall` (person, helper, outcome, cost — never the prompt or answer);
anonymising an account unlinks its calls, and calls go after 13 months. Anthropic is added to the
operator register, switched off until a site uses AI. Migration `20261030120000_ai_helpers`.
- v2 errors have a new code, `UNAVAILABLE` (503), for when the Hub or the AI service can't be
reached.
- **The five AI helpers' API** (supervisors and admins): `POST /api/v2/ai/draft-event` (the event
form from a description, with ticket options, early bird, schedule and a budget from the notes),
`/ai/summarise-report` (reports without people in them only), `/ai/draft-message` (email or
WhatsApp, with `{{name}}` and the event placeholders), `/ai/summarise-feedback` (an event's
feedback, sent without who answered; recorded in the access log) and `/ai/improve-text`
(improve, shorter, or only fix mistakes). Every helper is told today's date. Email addresses,
phone numbers and ID numbers in what's sent are swapped for placeholders and put back in the
answer, so they never leave the site. Two checks are worked out in code as well as by the AI: a
weekday that doesn't match its date, and costs a full event can't cover.
- **AI buttons in the app** (only where the licence has AI; supervisors and admins):
- New event → **Describe your event**: a few sentences fill in the form (details, dates, tickets,
early bird; course dates as sessions), with the checks and a budget from the notes to look at
first. Nothing changes until "Fill in the form".
- **Improve / Shorter / Fix mistakes** under the event description, the site banner, and the
email and WhatsApp message boxes; the suggestion replaces the text only with "Use this".
- **Draft with AI** in Email attendees and WhatsApp attendees: a short note becomes the email's
subject and body, or the WhatsApp message, with `{{name}}` and the event's details.
- **Summarise this report** on reports without people in them, and **Summarise feedback** per
event on the Feedback summary report.
- Every suggestion says "AI suggestion — check it before saving".
- **Privacy notice:** the built-in text says how the AI helpers use information and where requests
go (sections 4 and 9). Sites with their own published notice see the change in the notice
editor.
- `npm run ai:eval` (backend): the helpers' eval set — fixed examples run through the site's Hub
connection, with automatic checks on the facts and every answer printed to read.
- **MIGRATING-TO-2.md:** what changes for integrations and for whoever runs the server, with every
v1 address and its v2 replacement.
### Removed
- **API v1** (`/api/…`). Every screen has used `/api/v2` since 1.51.16. Any v1 address now answers
`410 Gone` with `code: "API_V1_REMOVED"`, except what outside senders still use: the provider
webhooks (`/api/webhooks/yoco|payfast|whatsapp` and WAWP's oldest `/api/whatsapp/webhook`) and
the links in messages already sent (calendar files and one-click unsubscribe answer `308` with
their v2 address; a mail app's one-click POST to the old address is still handled) —
`legacyV1Router` and `v1Removed` in `src/v2/links.ts`. The 43 v1 route files are gone (the
controllers stay: `v1Route` runs them behind v2 routes); so are the `Deprecation` headers
(`src/v2/deprecation.ts`), the v1 reference at `/docs` (it redirects to `/docs/v2`) and the
hand-written `backend/API_DOCUMENTATION.md` (now a pointer to `/docs/v2`).
- `node-fetch` from the backend's dependencies (nothing used it since the move to native `fetch`).
### Changed
- **Passwords are hashed with Argon2id** (`@node-rs/argon2`, 19 MiB, 2 passes) instead of bcrypt.
bcrypt hashes still sign in, and are re-hashed with Argon2id the first time each person signs in
(`needsRehash` in `src/config/auth.js`, used by `logIn`). `bcryptjs` stays only to check those.
- The campus-scope route test runs over the v2 routes (`tests/v2CampusScope.test.ts`): each staff
or supervisor route needs a campus-scope middleware or must be on the list of routes whose
service checks campuses itself. The read-only and impersonation lists name v2 paths only.
- e2e tests call `/api/v2` only (`v2()` in `e2e/fixtures.ts` returns the answer's `data`);
Playwright waits for `/api/v2/setup/needed`.
- Documented shapes corrected: the access log's `actions` (action → label) and a cash-up's
`cashDenominations` (notes and coins).
- **The React Compiler lint rules are errors again** (`set-state-in-effect`, `purity`,
`immutability`, `refs`, `static-components`); the 192 places they flagged are fixed, and
`npx eslint src` reports nothing. New hooks in `frontend/src/lib/hooks.ts`: `useStateSync` (state
that follows other state, set while rendering), `useExternalEffect` (loading from the API or
reading the browser), `useNow` (the time, refreshed each minute); `hardNavigate` in
`lib/navigation.ts` for the full reloads after signing out or switching who is signed in. Other
pages navigate with the router. Several resets moved into the handlers that cause them.
- Small behaviour fixes from that work: the site banner now appears and goes at its set times
without a reload; lists of past/upcoming events stay right on a page left open; registering
goes to the next page without a full reload; popups keep the latest close handler without
writing it during render.
## [1.52.3] - 2026-11-02
### Fixed
- **Licence read-only mode works again.** The read-only guard (`src/hub/readOnly.js`) only listed
v1 paths, and every screen has used `/api/v2` since 1.51.15 — so a suspended, revoked or
unlicensed site could still create and change events, take registrations and start payments.
The v2 successors of every blocked v1 route are now blocked too (answered as `READ_ONLY` with
reason `LICENCE_READ_ONLY`). A test checks each blocked v2 path is a real route in the OpenAPI
document, so a renamed route can't slip out of the list.
## [1.52.2] - 2026-11-02
### Changed
- **`scripts/deploy.sh` stops before touching anything when files in the install folder were
changed or added by hand**, and lists them. A setup wizard run on one server had added files to
`backend/src` that the backend build picked up, so the deploy failed halfway and rolled back.
Ignored files (`.env`, uploads, backups, `ecosystem.config.js`) never count.
`ALLOW_LOCAL_CHANGES=1` deploys anyway.
- `bash scripts/deploy.sh --check` runs only that check (no backup, fetch, build or reload). The
script can also be run from a copy outside the install (e.g. `/tmp/deploy.sh`, from the folder
you're in), so an older install can check before updating — see DEPLOYMENT.md, "Updating".
## [1.52.1] - 2026-11-02
### Changed
- **The API documentation (`/docs/v2`, `GET /api/v2/openapi.json`) now describes every route in
full.** Before, most routes answered by a v1 handler showed an empty body and `data: unknown`.
Each one now documents the body it reads and what it answers, from the handler's actual code, and
every path and query parameter has a description. Actions that take no input say "No body
needed".
- How: `documented: { body?, response? }` on `v1Route` (and `defineRoute`), with shared shapes in
`src/v2/docs.ts`. These are documentation only — requests and answers aren't checked against
them, so nothing an app already does can start failing — but outside production a body or
answer that doesn't fit is logged (`[api/v2] … doesn't match its documented schema`).
`tests/v2Docs.test.ts` keeps the document complete.
- The funnel report's `eventIds` and the webhook deliveries' `status`/`before` filters are now
declared (they worked, but weren't in the document or the typed frontend calls).
- The frontend's generated API types are richer; a few calls had their types tightened to match
(ticket transfer, breach register, kids check-out). Nothing changes in what they send.
## [1.52.0] - 2026-11-02 — urgent
### Fixed
- **Paid door and manual registrations no longer end up without tickets.** On an event with a
required attendee form, tickets wait until a form is in for each person — but At the door and
Manual registration never asked for the form, so payment was recorded and nobody could be checked
in. The form now pops up when someone is registered, selected or pays at the door (and after
registering or paying in Manual registration): fill it in, or **Fill in later**, which issues the
tickets once it's paid and sends the payer a link to the form (`POST
/api/v2/registrations/{id}/form-later`, staff; `Registration.formWaivedAt`). Closing the popup
counts as Fill in later. The door's check-in panel says when a form is holding tickets back.
- **Picked-session bookings needed a form per session.** A person booked on three sessions needed
three forms before their tickets were issued; each person now needs one (`formPlaces` in
`utils/ticketUtils.js`).
- **A required form with only statements and headings held tickets back for ever** (nothing to
answer). Only a required form with a question counts now (`formNeedsAnswers`). Every place that
decided this on its own (coupon making it free, manual and online registration) now asks the
ticket generator (`formsOutstanding`).
- **Self-service kiosk:** it asked for one form per main ticket picked, so a visitor adding to an
existing registration (or booking picked sessions) could finish with the forms still short and
no tickets. It now asks for the forms the server says are missing (`formsNeeded` on `POST
/api/v2/registrations/manual`). Headings on the form showed as an empty text box; they're shown
as text now, with the statements.
- Recording a payment answers `formsNeeded` when a form still holds the tickets back.
### Added
- **Corrections** (admins, `/dashboard/admin/corrections`): every registration at an event —
past and closed ones too — with what needs putting right. Issue all missing tickets at once
(forms left for later); fill in a form; issue tickets before payment ("pay later", the balance
is still owed — `Registration.ticketsReleasedAt`); make it free (the balance is written off as a
complimentary discount, `Registration.complimentaryDiscount`, part of `discountTotal`, shown as
"Complimentary" on invoices and receipts; not on closed events); and check a ticket in or undo it
after the event, for any session (a manual check-in, no message sent). Sending the tickets is a
choice. `/api/v2/corrections/…` (admin): `GET events/{id}`, `POST events/{id}/issue-missing`,
`POST registrations/{id}/tickets`, `POST registrations/{id}/complimentary`,
`POST tickets/{id}/check-in`. Code in `src/services/corrections.ts`.
- Audit actions `attendee_form_waived`, `tickets_issued_override`, `registration_complimentary`
and `checkin_corrected`. Migration `20261102120000_corrections`.
### Upgrading
- Update before your next event with a required attendee form. After updating, open Admin →
Corrections for a past event to issue any tickets that went missing (leave "Send them" off if
the event is over).
## [1.51.17] - 2026-10-30 — security release
### Security
- **Forgot password no longer reveals whether an account exists.** It answered "Email not found"
(404) for an unknown address, so anyone could check which emails have accounts. It now gives
the same answer and status for every address, sent before any work that only happens for a
real account (so the response time doesn't tell either); the reset link is made and sent
afterwards. Applies to `POST /api/users/forgot` and `POST /api/v2/auth/password/forgot`.
- **Sign-in no longer reveals it either.** An unknown email answered "User does not exist" (with a
different status on v1); it now gets exactly the answer a wrong password gets, after a password
check against a stand-in hash so it takes as long.
- Forgot password is rate-limited like sign-in, so it can't be used to flood someone's inbox with
reset emails.
### Upgrading
- Update as soon as you can. Nothing else changes; the forgot-password page shows the new message.
## [1.51.16] - 2026-10-30
### Added
- **Incoming webhooks on v2:** `/api/v2/webhooks/yoco`, `/payfast` and `/whatsapp`. New provider
set-ups use them — the PayFast notify URL (every new checkout), the Yoco webhook URL offered in
Site settings → Payments, and the WhatsApp webhook URL. The v1 addresses (`/api/webhooks/…`,
`/api/whatsapp/webhook`) keep working with the same handlers, because a provider may not follow
a redirect on a POST; they now name their v2 address in a `Link` header.
- **Links in sent messages on v2:** "Add to calendar" links point at
`/api/v2/events/{id}/calendar.ics`, and the one-click unsubscribe header at
`/api/v2/unsubscribe/{token}/one-click`. The same links in messages already sent answer `308`
with their v2 address; a mail app's one-click POST to the old address is still handled there.
### Fixed
- `/api/webhooks/whatsapp` was an empty stub that answered 500; it now runs the WhatsApp webhook
handler like `/api/whatsapp/webhook`.
### Upgrading
- Nothing has to change: existing Yoco and WhatsApp webhook registrations keep working. To move a
registration to its v2 address, generate the Yoco webhook again (Site settings → Payments → Yoco)
and paste the webhook URL shown in Site settings → WhatsApp into WAWP. PayFast moves by itself.
## [1.51.15] - 2026-10-30
### Added
- **API v2 — privacy and admin:** my privacy (`/me/privacy`, news, my data as JSON or PDF, requests,
accepting the notice), `/privacy/notice`, unsubscribe links (`/unsubscribe/{token}`), the privacy
admin (requests, notice drafts and publishing, operators, consent and access logs, readiness,
retention, breaches); `/admin/audit-log`, jobs, impersonation (`/admin/impersonate/{userId}`,
`/me/impersonation` and its end and log); site settings and first-run set-up (`/settings`,
`/setup`); the Hub; outbound webhooks; backups (including the download); Yoco and PayFast settings
(`/payments/yoco/…`, `/payments/payfast/…`).
- `v1Route` handles `res.download` and `res.sendFile`.
### Changed
- **Every API call the app makes now goes to `/api/v2`.** Only the incoming webhooks (Yoco,
PayFast, WhatsApp) and the links in sent messages are still v1 — those are 1.51.16.
- Ending a viewing-as session works on v2 while the session is read-only.
## [1.51.14] - 2026-10-29
### Added
- **API v2 — people and messaging:** the rest of accounts (`/auth/login/code/resend`,
`/auth/password/forgot` and `/reset`, `/auth/activate`, `/me/sessions/revoke`, `/me/close`,
`/me/activity`, `/me/verify-password`, `/me/two-factor/…`; admin `/users`, `/users/{id}`,
`/users/exists`, sessions and anonymising); `/event-listing` (the full public list the site's event
pages show) and `/admin/events`; messaging an event's people, email and WhatsApp broadcasts,
`/scheduled-messages`, `/automations/schedule`; the WhatsApp connection (`/whatsapp/…`); `/banner`;
`/saved-filters`; `/dashboard/today` and `/search`; `/stats/…`; `/analytics/…`; report files
(`/reports/pdf`, `/excel`, `/email`, `/whatsapp`).
- `defineRoute` refuses a method and path defined twice (the first would silently win).
- `GET /me` also answers `newsPromptPending`, `noticePromptPending`, `twoFactorRequired` and
`campusScope`, so the app's session (and its after-sign-in prompts) comes from v2.
### Changed
- Sign-in (including the self-service kiosk's), password reset, two-step sign-in, account
settings, admin users, messaging, broadcasts, WhatsApp set-up, dashboards, saved filters,
reports and the public event lists use v2.
## [1.51.13] - 2026-10-29
### Added
- **API v2 — kids check-in and volunteers:** my family (`/me/family`, its children and adults); kids
set-up and the check-in desk (`/events/{id}/kids/…`: settings, board, families, check-in and out,
collection codes, duty, offline snapshot and sync; `/kids/check-ins/{id}/…`, `/kids/alerts/{id}/resolve`);
serving (`/me/volunteering`, `/events/{id}/volunteer-roles`, sign-up, swaps, self check-in) and rosters
(`/events/{id}/volunteers` and its QR and settings, roles, assigning, manual check-in, hours).
### Changed
- The kids desk, rooms view, family page, serving page and volunteer rosters use v2.
## [1.51.12] - 2026-10-29
### Added
- **API v2 — tickets and attendance:** `/me/tickets` (+ `/email`), `/tickets` (the staff list, one
ticket, sending, issuing, scan stats and recent scans, the ticket for a QR code), transfers
(`/tickets/{id}/transfer`, `/ticket-transfers/{token}` to accept or decline), `/events/{id}/tickets`;
offline scanning (`/scan-locks`: take tickets offline, snapshot, sync, release, reports); the
attendance register (`/events/{id}/attendance`, its PDF/Excel export) and certificates (issue,
send, revoke, download, preview, verify, `/me/certificates`); allocations (`/events/{id}/allocations`,
`/allocation-sets`, `/allocation-groups`, `/me/allocation-groups`).
- Frontend: `apiV2Raw()` for screens whose helpers pass the path as a string; file downloads
read v2's error shape.
- `v1Route` can run a v1 router (with `map: () => ({ url })`); a chain that answers nothing is a
404 instead of waiting.
### Changed
- Tickets, transfers, offline scanning, attendance, certificates, allocations and the kids room
set-up use v2.
## [1.51.11] - 2026-10-29
### Added
- **API v2 — money:** payments (`/payments`, `/payments/{id}`, receipts, refunds, donations
assigned to registrations, stats, providers and their settings, open checkouts), payment links
(`/registrations/{id}/payment-link` (+ `/send`), `/payment-links/{id}` — the pay page),
`/donations/checkout`, `/me/payments`; cash-ups (`/events/{id}/cashup` and its counts, draft, close
and reopen, `/cashup-lines/{id}/pending`, `/cashups/audit`); provider transactions; the waitlist
(join, mine, offers by link, the event's list, offer/move/remove); sponsorship; parental consent
(the event's list, medical notes, reminders, the consent form by link); `/form-responses`; and
feedback (set-up, send now, the summary, the form by link).
- **`src/v2/viaV1.ts`:** `v1Route()` — a v2 route answered by the v1 handler, in-process, behind
v2's access checks, validation and docs. v1 JSON becomes `data`; v1 errors become v2 errors with
v1's `code` as `reason`; piped files become downloads.
- The v2 reference (`/docs/v2`) opens with its sections collapsed and has a filter box — with
more than about 130 endpoints Swagger UI stopped drawing the expanded list.
### Changed
- Every money screen (payments, at the door, cash-up, consent, waitlist, feedback, forms, the pay
and donate pages, the user dashboard) uses v2.
## [1.51.10] - 2026-10-29
### Added
+18
View File
@@ -311,6 +311,24 @@ 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.
Since 1.52.2 it first checks for files in the install folder that were changed or added by hand
(e.g. by a setup wizard run on the server) and stops, listing them, before it backs anything up
or checks anything out: they would be built into the release, or block the checkout. Move added
files out of the folder and put changed ones back with `git checkout -- <file>`, or deploy anyway
with `ALLOW_LOCAL_CHANGES=1`. To check without deploying:
```bash
sudo -u hopeevents -H bash scripts/deploy.sh --check
```
An install still on an older release can run the new check without updating anything — fetch the
script on its own and run it from the install folder:
```bash
git fetch -q origin && git show origin/main:scripts/deploy.sh > /tmp/deploy.sh
bash /tmp/deploy.sh --check
```
### Upgrading Node
Node 20 stopped getting security fixes in April 2026; Cross Events runs on Node 24 LTS (supported
+17 -3
View File
@@ -327,6 +327,12 @@ Feedback → Attendance**.
Date, plus Statement and Heading blocks; required/optional, help text,
reordering. The whole form can be marked required.
- **Drafts** — partially filled forms are auto-saved.
- **Required forms and tickets** — a required form (with at least one question)
holds a registration's tickets back until a form is in for each person (a
picked-session booking counts each person once). At the door and in Manual
registration the form pops up to fill in with the attendee; **Fill in later**
(any staff) issues the tickets anyway once it's paid and sends the payer a link
to the form. The kiosk asks the visitor for exactly the forms still missing.
- **Responses** — view, filter (event, user, past/inactive) and print one
response per A4 page. Supervisors can edit responses.
@@ -492,6 +498,14 @@ logout controls. Disconnected sessions are recovered automatically.
current filters, apply one with a click, star one as the default for the page
(not applied over a deep link). Per person, on the server.
- **Cashup** — see below.
- **Corrections** (`/dashboard/admin/corrections`, admins only) — for any event,
past and closed ones too: registrations missing tickets, with forms to fill in,
or owing money. **Issue all missing tickets** at once (forms left for later),
fill in a form, **issue tickets — pay later** (balance still owed), **make it
free** (the balance written off as a complimentary discount, so income stays
what was paid; not on closed events), and **check people in (or undo) after the
event** for any session. Optionally send the tickets. Every correction is in
the audit log with the reason given.
- **Audit log** (`/dashboard/admin/audit-log`) — who did what and when for
refunds, donation assign/unassign, manual registrations, staff
cancellations, event create/update/delete, settings changes, and coupon codes
@@ -889,8 +903,8 @@ can't start, the same jobs run on in-process timers.
- **Sentry** error monitoring when `SENTRY_DSN` / `NEXT_PUBLIC_SENTRY_DSN` are
set.
- Unhandled promise rejections are logged and don't crash the server.
- Backend status page (`/`) and built-in API docs page (`/docs`); **API v2** (`/api/v2`, 1.51.0)
with one error shape, paged lists and an OpenAPI 3.1 reference at `/docs/v2` (admins). Public
- Backend status page (`/`); **API v2** (`/api/v2`, 1.51.0 — the only API since 2.0, which removed
v1; `/docs` redirects to `/docs/v2`) with one error shape, paged lists and an OpenAPI 3.1 reference at `/docs/v2` (admins). Public
events, my account and registrations, registering, cancelling and paying online (1.51.1),
signing up, logging in (with the 2FA step), changing my details and checking tickets in (1.51.2;
the scanner and at-the-door use it from 1.51.3); the frontend's v2 calls are typed from the
@@ -938,4 +952,4 @@ can't start, the same jobs run on in-process timers.
| `/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 |
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/corrections`, `/audit-log`, `/data-requests`, `/privacy`, `/settings`) | Admin | Admin tools |
+408
View File
@@ -0,0 +1,408 @@
# Moving to Cross Events 2.0
Cross Events 2.0 removes version 1 of the API (`/api/…`). Everything the app does has been on
version 2 (`/api/v2/…`) since 1.51.16, so **a church that only uses the app has nothing to do** —
the update looks the same as any other. This guide is for anyone with their own integration (a
script, a website plug-in, a spreadsheet import) that calls the API directly, and for whoever runs
the server.
## What still works without changes
- **Payment and WhatsApp webhooks.** Yoco, PayFast and WAWP keep posting to the addresses they were
registered with: `/api/webhooks/yoco`, `/api/webhooks/payfast`, `/api/webhooks/whatsapp` and the
oldest WAWP address, `/api/whatsapp/webhook`. New registrations use `/api/v2/webhooks/…`.
- **Links in messages already sent.** "Add to calendar" links (`/api/events/{id}/ics`) and one-click
unsubscribe links (`/api/privacy/unsubscribe/{token}/one-click`) answer `308` with their v2
address; a mail app's one-click `POST` to the old address is still handled.
- **Outbound webhooks** (Site settings → Webhooks) send the same events and payloads as before.
- **Pages people open** — event pages, consent forms, ticket transfers, waitlist offers — are app
pages, not API addresses, so they were never affected.
Any other `/api/…` address now answers **`410 Gone`**:
```json
{ "code": "API_V1_REMOVED", "message": "API v1 was removed in Cross Events 2.0. Use /api/v2 — it is described at /docs/v2." }
```
`/docs` (the v1 reference) redirects to `/docs/v2`.
## How v2 differs
The full reference is at `/docs/v2` on your site (sign in as an admin), generated from the code so
it is always current. The OpenAPI 3.1 document is `GET /api/v2/openapi.json`.
| | v1 | v2 |
|---|---|---|
| Answers | whatever each endpoint returned | always `{ "data": … }` |
| Lists | plain arrays, some paged | `{ "data": [ … ], "meta": { "page", "limit", "total", "pages" } }`, `?page=` and `?limit=` (most lists up to 100) |
| Errors | `{ "message", "code"? }`, often `400` | `{ "error": { "code", "message", "reason"?, "fields"? } }` — `code` is one of a fixed set (`VALIDATION_FAILED`, `UNAUTHENTICATED`, `FORBIDDEN`, `NOT_FOUND`, `CONFLICT`, `READ_ONLY`, `RATE_LIMITED`, `UNAVAILABLE`, `INTERNAL`); v1's `code` is now `reason` |
| Input | anything | checked against the documented schema; mistakes are `VALIDATION_FAILED` with `fields` |
| Changing a record | mostly `PUT` | `PATCH` for partial changes (events, ticket options, variants, series, users, coupons…) |
| Dates | mixed | ISO 8601, UTC |
Things that behave differently, not just live at a new address:
- **Signing in:** `POST /api/v2/auth/login` takes `{ identifier, password }` (an email or phone
number). If two-step sign-in is on, the answer is `status: "code_sent"` with a `challengeId`;
finish with `POST /api/v2/auth/login/code`.
- **Signing up:** `POST /api/v2/auth/signup` needs `acceptTerms: true`; the phone number is
`phone`. The answer's `data` holds `token` and `user`.
- **Checking a ticket in:** `POST /api/v2/checkin/{qrCode}` with `{ people? }` (was `qty`). Every
normal result is a `200` with an `outcome` — `checked_in`, `already_used`, `consent_required`,
`wrong_event`, … — where v1 used `403`s. Look a ticket up first with `GET /api/v2/checkin/{qrCode}`.
- **Paying online:** `POST /api/v2/registrations/{id}/checkout` (the registration is in the path,
not the body).
- **Cancelling a registration:** `POST /api/v2/registrations/{id}/cancel` (was `DELETE`).
- **Staff lists of registrations** are paged (up to 200 a page).
- **A suspended or unlicensed site** refuses new events, registrations and payments with
`READ_ONLY` / reason `LICENCE_READ_ONLY`.
## For whoever runs the server
1. Deploy as usual: `bash scripts/deploy.sh v2.0.0` (it backs up, migrates, builds and rolls back
on failure). Run `bash scripts/deploy.sh --check` first if anyone may have changed files by hand.
2. Two migrations run: `20261030120000_ai_helpers` (the AI call log) and anything newer listed in the
CHANGELOG.
3. **AI helpers** need the site connected to the Hub (Site settings → Licence) and a monthly AI cap
above R0 on the licence. The Hub must run 1.6.0 or later. With no cap the AI buttons don't show
and nothing else changes.
4. If something outside Cross Events still calls `/api/…`, it will get `410` after the update — look
for those in the nginx access log before updating (`grep -v '/api/v2/' access.log | grep ' /api/'`,
leaving out the webhook and link addresses above).
5. Passwords are re-hashed with a stronger method the next time each person signs in; nothing to do.
## Every v1 address and its v2 replacement
`{id}` is the record's id. `{a|b}` means either. Methods that changed are shown in the v2 column.
| v1 | v2 |
|---|---|
| `GET /api/events` | `GET /api/v2/events` |
| `GET /api/events/{id}` | `GET /api/v2/events/{id}/full` |
| `GET /api/events/{id}/ics` | `GET /api/v2/events/{id}/calendar.ics` |
| `GET /api/events/by-alias/{id}` | `GET /api/v2/events/by-alias/{id}` |
| `POST /api/events/{id}/options` | `POST /api/v2/events/{id}/ticket-options` |
| `PUT /api/events/options/{id}` | `PATCH /api/v2/ticket-options/{id}` |
| `DELETE /api/events/options/{id}` | `DELETE /api/v2/ticket-options/{id}` |
| `POST /api/events/options/{id}/variants` | `POST /api/v2/ticket-options/{id}/variants` |
| `PUT /api/events/variants/{id}` | `PATCH /api/v2/variants/{id}` |
| `DELETE /api/events/variants/{id}` | `DELETE /api/v2/variants/{id}` |
| `GET /api/events/attachments/status` | `GET /api/v2/attachments/status` |
| `POST /api/events/attachments/sync` | `POST /api/v2/attachments/sync` |
| `GET /api/events/{id}/attachments` | `GET /api/v2/events/{id}/attachments` |
| `POST /api/events/{id}/attachments` | `POST /api/v2/events/{id}/attachments` |
| `DELETE /api/events/{id}/attachments/{id2}` | `DELETE /api/v2/events/{id}/attachments/{id2}` |
| `GET /api/sections` | `GET /api/v2/sections` |
| `POST /api/sections` | `POST /api/v2/sections` |
| `PUT /api/sections/{id}` | `PATCH /api/v2/sections/{id}` |
| `DELETE /api/sections/{id}` | `DELETE /api/v2/sections/{id}` |
| `GET /api/events/{id}/costs` | `GET /api/v2/events/{id}/costs` |
| `POST /api/events/{id}/costs` | `POST /api/v2/events/{id}/costs` |
| `PUT /api/costs/{id}` | `PATCH /api/v2/costs/{id}` |
| `DELETE /api/costs/{id}` | `DELETE /api/v2/costs/{id}` |
| `POST /api/uploads/{event-image|logo|favicon}` | `POST /api/v2/uploads/{event-image|logo|favicon}` |
| `GET /api/events/all` | `GET /api/v2/events/manage` |
| `POST /api/events` | `POST /api/v2/events` |
| `PUT /api/events/{id}` | `PATCH /api/v2/events/{id}` |
| `DELETE /api/events/{id}` | `DELETE /api/v2/events/{id}` |
| `POST /api/events/{id}/copy-from` | `POST /api/v2/events/{id}/copy-from` |
| `GET /api/events/{id}/notify-recipients` | `GET /api/v2/events/{id}/notify-recipients` |
| `PUT /api/events/{id}/notify-recipients` | `PUT /api/v2/events/{id}/notify-recipients` |
| `POST /api/webhooks/{yoco|payfast|whatsapp}` | `POST /api/v2/webhooks/{yoco|payfast|whatsapp}` |
| `POST /api/whatsapp/webhook` | `POST /api/v2/webhooks/whatsapp` |
| `POST /api/privacy/unsubscribe/{id}/one-click` | `POST /api/v2/unsubscribe/{id}/one-click` |
| `GET /api/privacy/me` | `GET /api/v2/me/privacy` |
| `PUT /api/privacy/me/news` | `PUT /api/v2/me/privacy/news` |
| `GET /api/privacy/me/export` | `GET /api/v2/me/privacy/export` |
| `POST /api/privacy/me/requests` | `POST /api/v2/me/privacy/requests` |
| `POST /api/privacy/me/notice/accept` | `POST /api/v2/me/privacy/notice/accept` |
| `GET /api/privacy/notice` | `GET /api/v2/privacy/notice` |
| `GET /api/privacy/unsubscribe/{id}` | `GET /api/v2/unsubscribe/{id}` |
| `POST /api/privacy/unsubscribe/{id}` | `POST /api/v2/unsubscribe/{id}` |
| `GET /api/privacy/requests` | `GET /api/v2/privacy/requests` |
| `POST /api/privacy/requests/{id}/{complete|reject}` | `POST /api/v2/privacy/requests/{id}/{complete|reject}` |
| `GET /api/privacy/notice/draft` | `GET /api/v2/privacy/notice/draft` |
| `PUT /api/privacy/notice/draft` | `PUT /api/v2/privacy/notice/draft` |
| `DELETE /api/privacy/notice/draft` | `DELETE /api/v2/privacy/notice/draft` |
| `POST /api/privacy/notice/{publish|builtin-reviewed}` | `POST /api/v2/privacy/notice/{publish|builtin-reviewed}` |
| `GET /api/privacy/operators` | `GET /api/v2/privacy/operators` |
| `POST /api/privacy/operators` | `POST /api/v2/privacy/operators` |
| `PUT /api/privacy/operators/{id}` | `PUT /api/v2/privacy/operators/{id}` |
| `DELETE /api/privacy/operators/{id}` | `DELETE /api/v2/privacy/operators/{id}` |
| `GET /api/privacy/{consent-log|access-log|readiness|retention|breaches}` | `GET /api/v2/privacy/{consent-log|access-log|readiness|retention|breaches}` |
| `PUT /api/privacy/retention` | `PUT /api/v2/privacy/retention` |
| `POST /api/privacy/retention/run` | `POST /api/v2/privacy/retention/run` |
| `POST /api/privacy/breaches` | `POST /api/v2/privacy/breaches` |
| `PUT /api/privacy/breaches/{id}` | `PUT /api/v2/privacy/breaches/{id}` |
| `GET /api/admin/{audit-log|jobs|impersonation-sessions}` | `GET /api/v2/admin/{audit-log|jobs|impersonation-sessions}` |
| `POST /api/admin/jobs/{id}/run` | `POST /api/v2/admin/jobs/{id}/run` |
| `POST /api/admin/impersonate/{end|log}` | `POST /api/v2/me/impersonation/{end|log}` |
| `GET /api/admin/impersonate/current` | `GET /api/v2/me/impersonation` |
| `POST /api/admin/impersonate/{id}` | `POST /api/v2/admin/impersonate/{id}` |
| `GET /api/settings/needs-setup` | `GET /api/v2/setup/needed` |
| `GET /api/settings/all` | `GET /api/v2/settings/all` |
| `GET /api/settings` | `GET /api/v2/settings` |
| `PUT /api/settings` | `PUT /api/v2/settings` |
| `POST /api/settings/test-smtp` | `POST /api/v2/settings/test-smtp` |
| `POST /api/setup/register` | `POST /api/v2/setup/register` |
| `POST /api/setup` | `POST /api/v2/setup` |
| `GET /api/hub/{status|notices}` | `GET /api/v2/hub/{status|notices}` |
| `POST /api/hub/{connect|heartbeat|update-request|update-snooze}` | `POST /api/v2/hub/{connect|heartbeat|update-request|update-snooze}` |
| `POST /api/hub/update-request/cancel` | `POST /api/v2/hub/update-request/cancel` |
| `GET /api/outbound-webhooks` | `GET /api/v2/outbound-webhooks` |
| `POST /api/outbound-webhooks` | `POST /api/v2/outbound-webhooks` |
| `POST /api/outbound-webhooks/deliveries/{id}/resend` | `POST /api/v2/outbound-webhook-deliveries/{id}/resend` |
| `PUT /api/outbound-webhooks/{id}` | `PATCH /api/v2/outbound-webhooks/{id}` |
| `DELETE /api/outbound-webhooks/{id}` | `DELETE /api/v2/outbound-webhooks/{id}` |
| `POST /api/outbound-webhooks/{id}/{rotate-secret|test}` | `POST /api/v2/outbound-webhooks/{id}/{rotate-secret|test}` |
| `GET /api/outbound-webhooks/{id}/deliveries` | `GET /api/v2/outbound-webhooks/{id}/deliveries` |
| `GET /api/backups` | `GET /api/v2/backups` |
| `POST /api/backups/run` | `POST /api/v2/backups/run` |
| `GET /api/backups/{id}/download` | `GET /api/v2/backups/{id}/download` |
| `GET /api/{yoco|payfast}/config` | `GET /api/v2/payments/{yoco|payfast}/config` |
| `PUT /api/{yoco|payfast}/config` | `PUT /api/v2/payments/{yoco|payfast}/config` |
| `GET /api/yoco/webhooks` | `GET /api/v2/payments/yoco/webhooks` |
| `POST /api/yoco/webhooks` | `POST /api/v2/payments/yoco/webhooks` |
| `DELETE /api/yoco/webhooks/{id}` | `DELETE /api/v2/payments/yoco/webhooks/{id}` |
| `POST /api/payfast/test` | `POST /api/v2/payments/payfast/test` |
| `POST /api/users/login/2fa/resend` | `POST /api/v2/auth/login/code/resend` |
| `POST /api/users/forgot` | `POST /api/v2/auth/password/forgot` |
| `POST /api/users/reset` | `POST /api/v2/auth/password/reset` |
| `POST /api/users/activate` | `POST /api/v2/auth/activate` |
| `POST /api/users/revoke-sessions` | `POST /api/v2/me/sessions/revoke` |
| `POST /api/users/close-account` | `POST /api/v2/me/close` |
| `GET /api/users/activity` | `GET /api/v2/me/activity` |
| `POST /api/users/verify-password` | `POST /api/v2/me/verify-password` |
| `POST /api/users/2fa/{enable|confirm|disable}` | `POST /api/v2/me/two-factor/{enable|confirm|disable}` |
| `GET /api/users/check-exists` | `GET /api/v2/users/exists` |
| `POST /api/users/{id}/revoke-sessions` | `POST /api/v2/users/{id}/sessions/revoke` |
| `POST /api/users/{id}/anonymize` | `POST /api/v2/users/{id}/anonymize` |
| `GET /api/users` | `GET /api/v2/users` |
| `GET /api/users/{?!profile$}{id}` | `GET /api/v2/users/{?!profile$}` |
| `PUT /api/users/{?!profile$}{id}` | `PATCH /api/v2/users/{?!profile$}` |
| `DELETE /api/users/{id}` | `DELETE /api/v2/users/{id}` |
| `GET /api/events/admin/all` | `GET /api/v2/admin/events` |
| `POST /api/events/{id}/{email|whatsapp}-attendees` | `POST /api/v2/events/{id}/{email|whatsapp}-attendees` |
| `POST /api/events/{id}/{email|whatsapp}-attendees/schedule` | `POST /api/v2/events/{id}/{email|whatsapp}-attendees/schedule` |
| `POST /api/broadcasts/{preview|send|schedule}` | `POST /api/v2/broadcasts/email/{preview|send|schedule}` |
| `POST /api/whatsapp-broadcasts/{preview|send|schedule}` | `POST /api/v2/broadcasts/whatsapp/{preview|send|schedule}` |
| `GET /api/scheduled-emails` | `GET /api/v2/scheduled-messages` |
| `PATCH /api/scheduled-emails/{id}` | `PATCH /api/v2/scheduled-messages/{id}` |
| `DELETE /api/scheduled-emails/{id}` | `DELETE /api/v2/scheduled-messages/{id}` |
| `POST /api/automations/schedule` | `POST /api/v2/automations/schedule` |
| `GET /api/whatsapp/{config|status|qr}` | `GET /api/v2/whatsapp/{config|status|qr}` |
| `POST /api/whatsapp/config` | `GET/PUT /api/v2/whatsapp/config` |
| `POST /api/whatsapp/create-instance` | `POST /api/v2/whatsapp/instance` |
| `POST /api/whatsapp/delete-instance` | `POST /api/v2/whatsapp/instance` |
| `POST /api/whatsapp/{request-code|logout|start|restart}` | `POST /api/v2/whatsapp/{request-code|logout|start|restart}` |
| `GET /api/banner` | `GET /api/v2/banner` |
| `POST /api/banner` | `GET/PUT /api/v2/banner` |
| `GET /api/saved-filters` | `GET /api/v2/saved-filters` |
| `POST /api/saved-filters` | `POST /api/v2/saved-filters` |
| `PUT /api/saved-filters/{id}` | `PUT /api/v2/saved-filters/{id}` |
| `DELETE /api/saved-filters/{id}` | `DELETE /api/v2/saved-filters/{id}` |
| `GET /api/dashboard/{today|search}` | `GET /api/v2/dashboard/{today|search}` |
| `GET /api/stats/{staff|supervisor|admin|overview}` | `GET /api/v2/stats/{staff|supervisor|admin|overview}` |
| `POST /api/analytics/track` | `POST /api/v2/analytics/track` |
| `GET /api/analytics/funnel` | `GET /api/v2/analytics/funnel` |
| `POST /api/reports/{pdf|excel|email|whatsapp}` | `POST /api/v2/reports/{pdf|excel|email|whatsapp}` |
| `GET /api/kids/me` | `GET /api/v2/me/family` |
| `POST /api/kids/me` | `POST /api/v2/me/family` |
| `PUT /api/kids/me` | `PATCH /api/v2/me/family` |
| `DELETE /api/kids/me` | `DELETE /api/v2/me/family` |
| `POST /api/kids/me/{children|adults}` | `POST /api/v2/me/family/{children|adults}` |
| `PUT /api/kids/me/{children|adults}/{id}` | `PUT /api/v2/me/family/{children|adults}/{id}` |
| `DELETE /api/kids/me/{children|adults}/{id}` | `DELETE /api/v2/me/family/{children|adults}/{id}` |
| `GET /api/kids/setup-events` | `GET /api/v2/kids/setup-events` |
| `GET /api/kids/events` | `GET /api/v2/kids/events` |
| `GET /api/kids/events/{id}/{settings|board|families|offline}` | `GET /api/v2/events/{id}/kids/{settings|board|families|offline}` |
| `PUT /api/kids/events/{id}/settings` | `PUT /api/v2/events/{id}/kids/settings` |
| `POST /api/kids/events/{id}/{families|check-in|check-out|duty|sync}` | `POST /api/v2/events/{id}/kids/{families|check-in|check-out|duty|sync}` |
| `GET /api/kids/events/{id}/families/qr/{id2}` | `GET /api/v2/events/{id}/kids/families/qr/{id2}` |
| `GET /api/kids/events/{id}/families/{id2}` | `GET /api/v2/events/{id}/kids/families/{id2}` |
| `POST /api/kids/events/{id}/families/{id2}/{parents|children|adults}` | `POST /api/v2/events/{id}/kids/families/{id2}/{parents|children|adults}` |
| `PUT /api/kids/events/{id}/families/{id2}/children/{id3}` | `PUT /api/v2/events/{id}/kids/families/{id2}/children/{id3}` |
| `GET /api/kids/events/{id}/code/{id2}` | `GET /api/v2/events/{id}/kids/code/{id2}` |
| `POST /api/kids/check-ins/{id}/{override|page}` | `POST /api/v2/kids/check-ins/{id}/{override|page}` |
| `PUT /api/kids/check-ins/{id}/room` | `PUT /api/v2/kids/check-ins/{id}/room` |
| `POST /api/kids/alerts/{id}/resolve` | `POST /api/v2/kids/alerts/{id}/resolve` |
| `GET /api/volunteers/me` | `GET /api/v2/me/volunteering` |
| `GET /api/volunteers/events/{id}` | `GET /api/v2/events/{id}/volunteer-roles` |
| `POST /api/volunteers/roles/{id}/sign-up` | `POST /api/v2/volunteer-roles/{id}/sign-up` |
| `DELETE /api/volunteers/slots/{id}` | `DELETE /api/v2/volunteer-slots/{id}` |
| `POST /api/volunteers/slots/{id}/swap` | `POST /api/v2/volunteer-slots/{id}/swap` |
| `DELETE /api/volunteers/swaps/{id}` | `DELETE /api/v2/volunteer-swaps/{id}` |
| `POST /api/volunteers/swaps/{id}/accept` | `POST /api/v2/volunteer-swaps/{id}/accept` |
| `POST /api/volunteers/check-in` | `POST /api/v2/volunteering/check-in` |
| `GET /api/volunteers/manage/events` | `GET /api/v2/volunteering/events` |
| `GET /api/volunteers/manage/hours` | `GET /api/v2/volunteering/hours` |
| `GET /api/volunteers/manage/events/{id}` | `GET /api/v2/events/{id}/volunteers` |
| `GET /api/volunteers/manage/events/{id}/{qr|settings}` | `GET /api/v2/events/{id}/volunteers/{qr|settings}` |
| `PUT /api/volunteers/manage/events/{id}/settings` | `PUT /api/v2/events/{id}/volunteers/settings` |
| `POST /api/volunteers/manage/events/{id}/roles` | `POST /api/v2/events/{id}/volunteer-roles` |
| `PUT /api/volunteers/manage/roles/{id}` | `PATCH /api/v2/volunteer-roles/{id}` |
| `DELETE /api/volunteers/manage/roles/{id}` | `DELETE /api/v2/volunteer-roles/{id}` |
| `POST /api/volunteers/manage/roles/{id}/assign` | `POST /api/v2/volunteer-roles/{id}/assign` |
| `POST /api/volunteers/manage/slots/{id}/check-in` | `POST /api/v2/volunteer-slots/{id}/check-in` |
| `DELETE /api/volunteers/manage/slots/{id}` | `POST /api/v2/volunteer-slots/{id}/remove` |
| `GET /api/tickets/mytickets` | `GET /api/v2/me/tickets` |
| `POST /api/tickets/email` | `POST /api/v2/me/tickets/email` |
| `GET /api/tickets/scans/recent` | `GET /api/v2/tickets/scans/recent` |
| `GET /api/tickets/scans/stats` | `GET /api/v2/tickets/scans/stats` |
| `POST /api/tickets/send-to` | `POST /api/v2/tickets/send` |
| `POST /api/tickets/generate` | `POST /api/v2/tickets/generate` |
| `PUT /api/tickets/email-sent` | `POST /api/v2/tickets/email-sent` |
| `GET /api/tickets/qr/{id}` | `GET /api/v2/tickets/qr/{id}` |
| `GET /api/tickets/event/{id}` | `GET /api/v2/events/{id}/tickets` |
| `GET /api/tickets/transfer-offer/{id}` | `GET /api/v2/ticket-transfers/{id}` |
| `POST /api/tickets/transfer-offer/{id}/{accept|decline}` | `POST /api/v2/ticket-transfers/{id}/{accept|decline}` |
| `GET /api/tickets` | `GET /api/v2/tickets` |
| `GET /api/tickets/{id}/transfers` | `GET /api/v2/tickets/{id}/transfers` |
| `POST /api/tickets/{id}/transfer` | `POST /api/v2/tickets/{id}/transfer` |
| `DELETE /api/tickets/{id}/transfer` | `DELETE /api/v2/tickets/{id}/transfer` |
| `GET /api/tickets/{id}` | `GET /api/v2/tickets/{id}` |
| `GET /api/scan-locks` | `GET /api/v2/scan-locks` |
| `POST /api/scan-locks` | `POST /api/v2/scan-locks` |
| `GET /api/scan-locks/reports` | `GET /api/v2/scan-locks/reports` |
| `POST /api/scan-locks/reports/{id}/review` | `POST /api/v2/scan-locks/reports/{id}/review` |
| `GET /api/scan-locks/{id}/snapshot` | `GET /api/v2/scan-locks/{id}/snapshot` |
| `POST /api/scan-locks/{id}/{sync|force-release}` | `POST /api/v2/scan-locks/{id}/{sync|force-release}` |
| `DELETE /api/scan-locks/{id}` | `DELETE /api/v2/scan-locks/{id}` |
| `GET /api/attendance/certificates/verify/{id}` | `GET /api/v2/certificates/verify/{id}` |
| `GET /api/attendance/certificates/mine` | `GET /api/v2/me/certificates` |
| `POST /api/attendance/certificates/preview` | `POST /api/v2/certificates/preview` |
| `GET /api/attendance/certificates/{id}/pdf` | `GET /api/v2/certificates/{id}/pdf` |
| `POST /api/attendance/certificates/{id}/revoke` | `POST /api/v2/certificates/{id}/revoke` |
| `GET /api/attendance/{id}/export` | `GET /api/v2/events/{id}/attendance/export` |
| `POST /api/attendance/{id}/mark` | `POST /api/v2/events/{id}/attendance` |
| `POST /api/attendance/{id}/certificates` | `POST /api/v2/events/{id}/certificates` |
| `POST /api/attendance/{id}/certificates/send` | `POST /api/v2/events/{id}/certificates/send` |
| `GET /api/attendance/{id}/certificates.pdf` | `GET /api/v2/events/{id}/certificates.pdf` |
| `GET /api/attendance/{id}` | `GET /api/v2/events/{id}/attendance` |
| `GET /api/allocations/my-groups` | `GET /api/v2/me/allocation-groups` |
| `GET /api/allocations/event/{id}` | `GET /api/v2/events/{id}/allocations` |
| `POST /api/allocations/sets` | `POST /api/v2/allocation-sets` |
| `PUT /api/allocations/sets/{id}` | `PATCH /api/v2/allocation-sets/{id}` |
| `DELETE /api/allocations/sets/{id}` | `DELETE /api/v2/allocation-sets/{id}` |
| `GET /api/allocations/sets/{id}/lists` | `GET /api/v2/allocation-sets/{id}/lists` |
| `POST /api/allocations/sets/{id}/{groups|auto}` | `POST /api/v2/allocation-sets/{id}/{groups|auto}` |
| `PUT /api/allocations/sets/{id}/assignments` | `PUT /api/v2/allocation-sets/{id}/assignments` |
| `DELETE /api/allocations/sets/{id}/assignments` | `DELETE /api/v2/allocation-sets/{id}/assignments` |
| `PUT /api/allocations/groups/{id}` | `PATCH /api/v2/allocation-groups/{id}` |
| `DELETE /api/allocations/groups/{id}` | `DELETE /api/v2/allocation-groups/{id}` |
| `GET /api/payments/providers` | `GET /api/v2/payments/providers` |
| `GET /api/payments/providers/settings` | `GET /api/v2/payments/providers/settings` |
| `PUT /api/payments/providers/settings` | `PUT /api/v2/payments/providers/settings` |
| `GET /api/payments/pay/{id}` | `GET /api/v2/payment-links/{id}` |
| `POST /api/payments/pay/{id}` | `POST /api/v2/payment-links/{id}` |
| `GET /api/payments/checkouts/open` | `GET /api/v2/payments/open-checkouts` |
| `POST /api/payments/checkouts/check` | `POST /api/v2/payments/open-checkouts/check` |
| `POST /api/payments/[yoco-]checkout/send` | `POST /api/v2/registrations/{id}/payment-link` |
| `GET /api/payments/mypayments` | `GET /api/v2/me/payments` |
| `GET /api/payments/admin/stats` | `GET /api/v2/payments/stats` |
| `GET /api/payments/registration/{id}` | `GET /api/v2/registrations/{id}/payments` |
| `GET /api/payments/event/{id}` | `GET /api/v2/events/{id}/payments` |
| `PUT /api/payments/assign-donation` | `POST /api/v2/payments/donations/assign` |
| `POST /api/payments/unassign-donation` | `POST /api/v2/payments/donations/unassign` |
| `POST /api/payments/refund` | `POST /api/v2/payments/refunds` |
| `POST /api/payments/{id}/send-receipt` | `POST /api/v2/payments/{id}/receipt` |
| `GET /api/payments` | `GET /api/v2/payments` |
| `POST /api/payments` | `POST /api/v2/payments` |
| `GET /api/payments/{id}` | `GET /api/v2/payments/{id}` |
| `GET /api/cashups/audit` | `GET /api/v2/cashups/audit` |
| `GET /api/cashups/event/{id}` | `GET /api/v2/events/{id}/cashup` |
| `GET /api/cashups/event/{id}/cash-by-user` | `GET /api/v2/events/{id}/cashup/cash-by-user` |
| `PUT /api/cashups/event/{id}/person-cash/{id2}` | `PUT /api/v2/events/{id}/cashup/person-cash/{id2}` |
| `PUT /api/cashups/event/{id}/draft` | `PUT /api/v2/events/{id}/cashup/draft` |
| `POST /api/cashups/event/{id}/close` | `POST /api/v2/events/{id}/cashup/close` |
| `POST /api/cashups/event/{id}/reopen` | `POST /api/v2/events/{id}/cashup/reopen` |
| `POST /api/cashups/lines/{id}/pending` | `POST /api/v2/cashup-lines/{id}/pending` |
| `GET /api/{provider|yoco}-transactions` | `GET /api/v2/provider-transactions` |
| `GET /api/{provider|yoco}-transactions/unreconciled` | `GET /api/v2/provider-transactions/unreconciled` |
| `POST /api/{?:provider|yoco}-transactions/{id}/reconcile` | `POST /api/v2/provider-transactions/{?:provider|yoco}/reconcile` |
| `POST /api/{?:provider|yoco}-transactions/{id}/ignore` | `POST /api/v2/provider-transactions/{?:provider|yoco}/ignore` |
| `POST /api/waitlist` | `POST /api/v2/waitlist` |
| `GET /api/waitlist/mine` | `GET /api/v2/me/waitlist` |
| `GET /api/waitlist/claim/{id}` | `GET /api/v2/waitlist/claims/{id}` |
| `GET /api/waitlist/events/{id}` | `GET /api/v2/events/{id}/waitlist` |
| `POST /api/waitlist/{id}/{offer|move|remove}` | `POST /api/v2/waitlist/{id}/{offer|move|remove}` |
| `DELETE /api/waitlist/{id}` | `DELETE /api/v2/waitlist/{id}` |
| `GET /api/sponsorships/event/{id}` | `GET /api/v2/events/{id}/sponsorship` |
| `GET /api/sponsorships/event/{id}/pool` | `GET /api/v2/events/{id}/sponsorship/pool` |
| `POST /api/sponsorships/checkout` | `POST /api/v2/sponsorships/checkout` |
| `GET /api/consent/events/{id}` | `GET /api/v2/events/{id}/consent` |
| `GET /api/consent/events/{id}/medical` | `GET /api/v2/events/{id}/consent/medical` |
| `POST /api/consent/events/{id}/{resend-pending|rerequest}` | `POST /api/v2/events/{id}/consent/{resend-pending|rerequest}` |
| `POST /api/consent/requests/{id}/resend` | `POST /api/v2/consent-requests/{id}/resend` |
| `GET /api/consent/{id}` | `GET /api/v2/consent/{id}` |
| `POST /api/consent/{id}` | `POST /api/v2/consent/{id}` |
| `GET /api/forms/responses` | `GET /api/v2/form-responses` |
| `GET /api/feedback/summary` | `GET /api/v2/feedback/summary` |
| `GET /api/feedback/public/{id}` | `GET /api/v2/feedback-forms/{id}` |
| `POST /api/feedback/public/{id}` | `POST /api/v2/feedback-forms/{id}` |
| `GET /api/feedback/event/{id}` | `GET /api/v2/events/{id}/feedback` |
| `PUT /api/feedback/event/{id}` | `PUT /api/v2/events/{id}/feedback` |
| `DELETE /api/feedback/event/{id}` | `DELETE /api/v2/events/{id}/feedback` |
| `POST /api/feedback/event/{id}/send-now` | `POST /api/v2/events/{id}/feedback/send-now` |
| `GET /api/registrations` | `GET /api/v2/registrations` |
| `GET /api/registrations/myregistrations` | `GET /api/v2/me/registrations/full` |
| `GET /api/registrations/event/{id}` | `GET /api/v2/events/{id}/registrations` |
| `POST /api/registrations/manual` | `GET /api/v2/registrations/manual` |
| `POST /api/registrations/bulk` | `GET /api/v2/registrations/bulk` |
| `PUT /api/registrations/{id}/options` | `PUT /api/v2/registrations/{id}/items` |
| `POST /api/registrations/{id}/send-invoice` | `POST /api/v2/registrations/{id}/invoice` |
| `POST /api/registrations/{id}/coupon` | `POST /api/v2/registrations/{id}/coupon` |
| `DELETE /api/registrations/{id}/coupon` | `DELETE /api/v2/registrations/{id}/coupon` |
| `PUT /api/registrations/{id}/attendees` | `PUT /api/v2/registrations/{id}/attendees` |
| `POST /api/registrations/{id}/attendees/{id2}/send-form-link` | `POST /api/v2/registrations/{id}/attendees/{id2}/form-link` |
| `GET /api/registrations/{id}/forms/draft` | `GET /api/v2/registrations/{id}/form-draft` |
| `PUT /api/registrations/{id}/forms/draft` | `PUT /api/v2/registrations/{id}/form-draft` |
| `POST /api/registrations/{id}/forms/responses` | `POST /api/v2/registrations/{id}/form-responses` |
| `PUT /api/registrations/{id}/forms/responses` | `PUT /api/v2/registrations/{id}/form-responses` |
| `GET /api/registrations/{id}` | `GET /api/v2/registrations/{id}/full` |
| `PUT /api/registrations/{id}` | `PATCH /api/v2/registrations/{id}/status` |
| `GET /api/series` | `GET /api/v2/series` |
| `POST /api/series` | `POST /api/v2/series` |
| `GET /api/series/by-slug/{id}` | `GET /api/v2/series/by-slug/{id}` |
| `GET /api/series/{id}/compare` | `GET /api/v2/series/{id}/compare` |
| `POST /api/series/{id}/follow` | `PUT/DELETE /api/v2/series/{id}/follow` |
| `DELETE /api/series/{id}/follow` | `DELETE /api/v2/series/{id}/follow` |
| `PUT /api/series/{id}` | `PATCH /api/v2/series/{id}` |
| `DELETE /api/series/{id}` | `DELETE /api/v2/series/{id}` |
| `GET /api/season-passes/mine` | `GET /api/v2/me/season-passes` |
| `POST /api/season-passes/purchases/{id}/checkout` | `POST /api/v2/season-pass-purchases/{id}/checkout` |
| `POST /api/season-passes/{id}/buy` | `POST /api/v2/season-passes/{id}/buy` |
| `GET /api/season-passes` | `GET /api/v2/season-passes` |
| `POST /api/season-passes` | `POST /api/v2/season-passes` |
| `PUT /api/season-passes/{id}` | `PATCH /api/v2/season-passes/{id}` |
| `DELETE /api/season-passes/{id}` | `DELETE /api/v2/season-passes/{id}` |
| `POST /api/bundles/quote` | `PATCH/DELETE /api/v2/bundles/quote` |
| `GET /api/bundles/event/{id}` | `GET /api/v2/events/{id}/bundles` |
| `POST /api/bundles/event/{id}` | `POST /api/v2/events/{id}/bundles` |
| `PUT /api/bundles/{id}` | `PATCH /api/v2/bundles/{id}` |
| `DELETE /api/bundles/{id}` | `DELETE /api/v2/bundles/{id}` |
| `POST /api/coupons/validate` | `GET/PATCH /api/v2/coupons/check` |
| `GET /api/coupons/redemptions` | `GET /api/v2/coupons/redemptions` |
| `GET /api/coupons` | `GET /api/v2/coupons` |
| `POST /api/coupons` | `POST /api/v2/coupons` |
| `GET /api/coupons/{id}` | `GET /api/v2/coupons/{id}` |
| `PUT /api/coupons/{id}` | `PATCH /api/v2/coupons/{id}` |
| `GET /api/campuses` | `GET /api/v2/campuses` |
| `GET /api/campuses/admin` | `GET /api/v2/campuses/admin` |
| `GET /api/campuses/by-slug/{id}` | `GET /api/v2/campuses/by-slug/{id}` |
| `PUT /api/campuses/settings` | `PUT /api/v2/campuses/enabled` |
| `PUT /api/campuses/order` | `PUT /api/v2/campuses/order` |
| `POST /api/campuses` | `POST /api/v2/campuses` |
| `GET /api/campuses/{id}/audience` | `GET /api/v2/campuses/{id}/audience` |
| `PUT /api/campuses/{id}` | `PATCH /api/v2/campuses/{id}` |
| `DELETE /api/campuses/{id}` | `DELETE /api/v2/campuses/{id}` |
| `GET /api/users/profile` | `GET /api/v2/me` |
| `PUT /api/users/profile` | `PATCH /api/v2/me` |
| `POST /api/users` | `POST /api/v2/auth/signup` |
| `POST /api/users/login` | `POST /api/v2/auth/login` |
| `POST /api/users/login/2fa` | `POST /api/v2/auth/login/code` |
| `POST /api/registrations` | `POST /api/v2/registrations` |
| `DELETE /api/registrations/{id}` | `POST /api/v2/registrations/{id}/cancel` |
| `POST /api/payments/checkout` | `POST /api/v2/registrations/{id}/checkout` |
| `GET /api/tickets/scan-preview/{id}` | `GET /api/v2/checkin/{id}` |
| `POST /api/tickets/scan/{id}` | `POST /api/v2/checkin/{id}` |
+8 -3
View File
@@ -20,7 +20,8 @@ The backend provides a RESTful API for managing users, events, registrations, pa
- Static uploads for event images
Documentation: see Backend API Documentation and Backend README for environment setup and endpoints.
- Backend API Documentation: ./backend/API_DOCUMENTATION.md
- API reference: `/docs/v2` on the running backend (OpenAPI; admin token) — ./backend/API_DOCUMENTATION.md
- Moving to 2.0 (v1 API removed): ./MIGRATING-TO-2.md
- Backend README: ./backend/README.md
### Frontend
@@ -137,7 +138,7 @@ set names that match your PM2 apps (`pm2 ls`) and the port you use.
2. Set all required environment variables (see backend/README.md).
3. Run Prisma migrations (if applicable).
4. Start the server with: npm start (or a process manager like PM2).
5. Expose the webhook endpoint /api/webhooks/yoco publicly and configure the YOCO webhook to point to it.
5. Expose the webhook endpoint /api/v2/webhooks/yoco publicly and register it with Yoco (Site settings → Payments → Yoco → Generate does this).
6. Ensure the uploads directory public/uploads exists and is writable.
### Frontend Deployment
@@ -155,7 +156,8 @@ set names that match your PM2 apps (`pm2 ls`) and the port you use.
## Useful Links
- Backend API Documentation: ./backend/API_DOCUMENTATION.md
- API reference: `/docs/v2` on the running backend (OpenAPI; admin token) — ./backend/API_DOCUMENTATION.md
- Moving to 2.0 (v1 API removed): ./MIGRATING-TO-2.md
- Backend README: ./backend/README.md
- Frontend README: ./frontend/README.md
- Site Settings — defaults reference: ./SETTINGS.md
@@ -163,6 +165,9 @@ set names that match your PM2 apps (`pm2 ls`) and the port you use.
## Recent Changes
> Older notes, kept for history. The `/api/…` paths they mention are API v1, removed in 2.0 —
> MIGRATING-TO-2.md lists each one's v2 replacement. Since 1.33 the changes are in CHANGELOG.md.
### Sold-out handling, tiered stock warnings & UI polish
**Backend**
+3 -3
View File
@@ -60,7 +60,7 @@ or Branding tab has been saved even once — the wizard always writes
## Payments
Managed in the Payments tab (through `/api/payments/providers/settings` and `/api/yoco`, not the generic settings endpoints).
Managed in the Payments tab (through `/api/v2/payments/providers/settings` and `/api/v2/payments/yoco/config`, not the generic settings endpoints).
| Setting | Default when unset | Notes |
|---|---|---|
@@ -89,7 +89,7 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
| 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. |
| `campuses_enabled` | `false` | Site settings → Campuses → "Use campuses". Changed only through `PUT /api/v2/campuses/enabled` (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)
@@ -106,7 +106,7 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
| 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`. |
| `WAWP_WEBHOOK_SECRET` | Generated (random) the first time the WhatsApp tab is opened | Part of the webhook URL shown in Site Settings → WhatsApp (`/api/v2/webhooks/whatsapp?secret=…`; set-ups from before 1.51.16 use `/api/whatsapp/webhook?secret=…`, which still works). 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)
+91
View File
@@ -12,6 +12,97 @@ Write for church admins, not developers: no environment variables, endpoints or
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
-->
## 2.0.0
- **AI helpers.** Where you already work, a button can now do the first draft for you: describe
an event in a sentence and the new-event form is filled in; turn a short note into a friendly
email or WhatsApp message; get a plain-language summary under a report or of an event's
feedback; or improve, shorten or correct a piece of text. You always see the suggestion first —
nothing is saved or sent until you do it. The buttons appear when your licence includes an AI
allowance (ask us about it); Site settings → AI shows what's been used this month. Only what a
helper needs is sent: email addresses and phone numbers in your text stay on your site, reports
are only summarised when they list no people, and feedback goes without names.
- **Stronger password protection.** Passwords are now stored with a stronger, modern method. Nobody
needs to do anything: each person's password is upgraded the next time they sign in.
- **The site banner keeps time.** A banner with start and end times now appears and disappears at
those times on pages that are already open.
- **For anyone with their own integration** (a script or website plug-in that talks to Cross Events
directly): the old version of the connection was retired. Payments, WhatsApp replies and links in
emails you've already sent keep working. Whoever looks after your integration can ask us for the
migration guide.
## 1.52.3
- **A licence check fix.** When a site's licence has run out or isn't connected, the site is
meant to go read-only (tickets still scan and all data can still be viewed and exported). Since
1.51.15 that wasn't being applied; it is again. Sites with an active licence see no change.
## 1.52.2
- **Safer updates.** If files on your server were changed by hand, an update now stops straight
away and says which files, instead of failing part-way. Nothing else changes.
## 1.52.1
- **Better documentation for developers.** If someone builds on your Cross Events site (for example
an integration or a website plug-in), the API reference now explains every part of it in full.
Nothing changes in how the site works.
## 1.52.0 — urgent
- **Paying at the door always gives people their tickets now.** On events with a required
attendee form, people registered at the door or in Manual registration were paid but got no
tickets, so they couldn't be checked in. The form now pops up to fill in with them — or tap
**Fill in later**: the tickets are issued straight away and they're sent a link to fill in the
form afterwards.
- **New: Corrections (for admins).** Pick any event, even one that's over, to see who's missing
tickets, still has a form to fill in or owes money. Issue all missing tickets in one click, give
someone their tickets now and let them pay later, make a ticket free, or check people in after
the event.
- The self-service kiosk asks for exactly the forms still needed, and shows headings on the form
as text instead of an empty box.
- People booked on several sessions now fill in one form each, not one per session.
## 1.51.17 — security update
- **Better protection for people's accounts.** The forgot-password and sign-in pages no longer
let anyone find out whether an email address has an account here.
## 1.51.16
- **The move to the new server connection is complete.** Payment confirmations from Yoco and
PayFast, WhatsApp messages coming in, and the calendar and unsubscribe links in emails now use
the new addresses. Links in emails you've already sent keep working.
## 1.51.15
- **The app now talks to the server only through its new version.** Privacy settings, the admin
pages, site settings, the Hub, webhooks and backups were the last to move. Everything works just
as before.
## 1.51.14
- **More groundwork for 2.0.** Accounts, messaging and broadcasts, WhatsApp set-up, the dashboards,
saved filters and report files now run on the new version of the server's connection to the
app. They work just as before.
## 1.51.13
- **More groundwork for 2.0.** Kids check-in and volunteer rosters now run on the new version of
the server's connection to the app. They work just as before.
## 1.51.12
- **More groundwork for 2.0.** Tickets, ticket transfers, offline scanning, the attendance register,
certificates and group allocations now run on the new version of the server's connection to the
app. They work just as before.
## 1.51.11
- **More groundwork for 2.0.** Payments, cash-ups, the waitlist, sponsorship, consent forms and
feedback now run on the new version of the server's connection to the app. They work just as
before.
## 1.51.10
- **More groundwork for 2.0.** Registrations — at the door, in the dashboards, attendee forms and
File diff suppressed because it is too large Load Diff
+235 -45
View File
@@ -1,15 +1,16 @@
{
"name": "event-management-backend",
"version": "1.51.10",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.51.10",
"version": "2.0.0",
"hasInstallScript": true,
"dependencies": {
"@cj-tech-master/excelts": "^10.2.0",
"@node-rs/argon2": "^2.2.2",
"@prisma/adapter-pg": "^7.10.1",
"@prisma/client": "^7.10.1",
"@sentry/node": "^10.71.0",
@@ -23,7 +24,6 @@
"ics": "^3.12.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.5.0",
"node-fetch": "^2.7.0",
"nodemailer": "^10.3.0",
"pdfkit": "^0.17.1",
"pg-boss": "^11.1.2",
@@ -1729,6 +1729,238 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@node-rs/argon2": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.2.2.tgz",
"integrity": "sha512-NtdlPPWtR5U4BxX5EGJYza+UklNaoWORmkaKCMqf/nyKeXWKVwNt8KfQoRpNT7OB1DcEwmePafO4wZyW+sbfCQ==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@node-rs/argon2-android-arm-eabi": "2.2.2",
"@node-rs/argon2-android-arm64": "2.2.2",
"@node-rs/argon2-darwin-arm64": "2.2.2",
"@node-rs/argon2-darwin-x64": "2.2.2",
"@node-rs/argon2-freebsd-x64": "2.2.2",
"@node-rs/argon2-linux-arm-gnueabihf": "2.2.2",
"@node-rs/argon2-linux-arm64-gnu": "2.2.2",
"@node-rs/argon2-linux-arm64-musl": "2.2.2",
"@node-rs/argon2-linux-x64-gnu": "2.2.2",
"@node-rs/argon2-linux-x64-musl": "2.2.2",
"@node-rs/argon2-win32-arm64-msvc": "2.2.2",
"@node-rs/argon2-win32-ia32-msvc": "2.2.2",
"@node-rs/argon2-win32-x64-msvc": "2.2.2"
}
},
"node_modules/@node-rs/argon2-android-arm-eabi": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.2.2.tgz",
"integrity": "sha512-c11BOrPLBWNC1cDyzGB+Bil+flmxfZbB2hv8EhcobmAsFKMyqkHPNhUY4qhcjv1U+4+Vmw4G8H+l224iO2g/Dw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-android-arm64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.2.2.tgz",
"integrity": "sha512-dpMYIoxhN/wY6hFH+3MM5kQErcH3luMekkszbkDF3NKqhB1kFRntkOLDpFY87oItBSHJrsoYkXC3vBRhjYoCjQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-darwin-arm64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.2.2.tgz",
"integrity": "sha512-vxVAXcIG0iiHizkCuImmzVhOFsRnU4Z79xiOxdISLyI+QhOAWbbTaVSXw0PNfThB3zAdkzzDJ/WWu52Hs/qHGg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-darwin-x64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.2.2.tgz",
"integrity": "sha512-r7GbSFpFZZ9EAV9cvIm9prUjOupK7QQXTyP+FJ6bWSMq0h+qRDBlUOXQXduDIIrLEGoBVelixKUtf4ksZ0ljoQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-freebsd-x64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.2.2.tgz",
"integrity": "sha512-AmWxDSmQgprilD6NX3jmxCgcDvWAW7kGJnnWQo+0qav6nfMXdPZYgSezK/skmw1GBWipr4yZMIUzpeNHnVpMxg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-arm-gnueabihf": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.2.2.tgz",
"integrity": "sha512-M1gnPBTr9GFeEUWkQ53qCJeTsX03C3kcUeHnDbN7dU2xkI58wMP4Z1sEBN7WmKJ/fTHYEgti4truz7r4A82DLg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-arm64-gnu": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.2.2.tgz",
"integrity": "sha512-4CYDlIdITlLSVcAXYUtEoGUahRaayPXM+4Rcfg3BZx9WGi6UB8HUycs6GNMTPYXV5kVXCpyE2u1HmYUpgmIzOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-arm64-musl": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.2.2.tgz",
"integrity": "sha512-FYlfj3XgQ1xrx6HzQbKbUcKiYMG/3iSi/X81KyhHaItTJ7vZlMwrbVLl9yqDmvotKUF6VjrhrlZ8KRbbzchseA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-x64-gnu": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.2.2.tgz",
"integrity": "sha512-ULXLs8UFYZANlwiROM7AeccxZndr2Xk3IstADq3jAUhnwV2W75mxMBTABUZ+7AdKRhPLOrxU77OgnMWlDkYJQg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-x64-musl": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.2.2.tgz",
"integrity": "sha512-zgjIwc9TyGodn62vGc+36DGwFqVV5e7L8Ptd2FQWuBxe5aFrcFxvtDGn1/qfYwoHLv09utgtR+RBAPCXepWG3Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-win32-arm64-msvc": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.2.2.tgz",
"integrity": "sha512-3nLhvLie/ec5xfxUBXXqGMb7oGeqF+YXrcGckqaTaGkPtOBozho0Kh2IuP/zi9BcOriirTG2LW3gtIOfpuh+mQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-win32-ia32-msvc": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.2.2.tgz",
"integrity": "sha512-6RJKD6cFl7UeiYJLjRsXPywVXSFSaSDdr6xGpztNDV31QHCEecWnsnNmKxzi4gVpln2F9XeWEtIgM4Nqw1hLbA==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-win32-x64-msvc": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.2.2.tgz",
"integrity": "sha512-dQOO9S4QKBaVul0Uyk/2quJc/OBeHNZRJP+GZZTy1A5kYIaAfIJGHUYLY6oFoAlcuLIkdFouLaHX3fCl8tZm/w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
@@ -7492,26 +7724,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -9096,12 +9308,6 @@
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
"license": "MIT"
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-jest": {
"version": "29.4.15",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.15.tgz",
@@ -9421,22 +9627,6 @@
"node": ">= 0.8"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.51.10",
"version": "2.0.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -17,10 +17,12 @@
"set-demo": "node scripts/set-demo.js",
"build": "tsc -p tsconfig.json && node -e \"require('fs').writeFileSync('dist/build.json', JSON.stringify({ version: require('./package.json').version, builtAt: new Date().toISOString() }))\"",
"typecheck": "tsc -p tsconfig.json --noEmit",
"openapi": "tsx scripts/openapi.ts"
"openapi": "tsx scripts/openapi.ts",
"ai:eval": "tsx scripts/ai-eval.ts"
},
"dependencies": {
"@cj-tech-master/excelts": "^10.2.0",
"@node-rs/argon2": "^2.2.2",
"@prisma/adapter-pg": "^7.10.1",
"@prisma/client": "^7.10.1",
"@sentry/node": "^10.71.0",
@@ -34,7 +36,6 @@
"ics": "^3.12.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.5.0",
"node-fetch": "^2.7.0",
"nodemailer": "^10.3.0",
"pdfkit": "^0.17.1",
"pg-boss": "^11.1.2",
@@ -0,0 +1,28 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'ai_settings_changed';
-- CreateTable
CREATE TABLE "AiCall" (
"id" TEXT NOT NULL,
"userId" TEXT,
"helper" TEXT NOT NULL,
"outcome" TEXT NOT NULL,
"chargedCents" DOUBLE PRECISION NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AiCall_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AiCall_createdAt_idx" ON "AiCall"("createdAt");
-- CreateIndex
CREATE INDEX "AiCall_userId_createdAt_idx" ON "AiCall"("userId", "createdAt");
-- AddForeignKey
ALTER TABLE "AiCall" ADD CONSTRAINT "AiCall_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- 2.0 AI helpers: Anthropic in the operator register, off until the site uses AI.
INSERT INTO "Operator" ("id", "name", "purpose", "dataShared", "location", "website", "active", "order", "updatedAt") VALUES
(gen_random_uuid()::text, 'Anthropic (Claude), through the software provider''s Hub', 'AI helpers for staff: drafting events and messages, improving text, summarising reports and feedback', 'The text a staff member asks to draft or improve, report figures, and feedback comments with names and contact details removed. Not kept by the software provider; Anthropic''s commercial terms apply', 'United States', 'https://www.anthropic.com/legal/privacy', false, 9, CURRENT_TIMESTAMP);
@@ -0,0 +1,12 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'attendee_form_waived';
ALTER TYPE "AdminAuditAction" ADD VALUE 'tickets_issued_override';
ALTER TYPE "AdminAuditAction" ADD VALUE 'registration_complimentary';
ALTER TYPE "AdminAuditAction" ADD VALUE 'checkin_corrected';
-- AlterTable
ALTER TABLE "Registration" ADD COLUMN "formWaivedAt" TIMESTAMP(3),
ADD COLUMN "formWaivedById" TEXT,
ADD COLUMN "ticketsReleasedAt" TIMESTAMP(3),
ADD COLUMN "ticketsReleasedById" TEXT,
ADD COLUMN "complimentaryDiscount" DOUBLE PRECISION NOT NULL DEFAULT 0;
+33
View File
@@ -165,6 +165,7 @@ model User {
seriesFollows SeriesFollow[]
seasonPassPurchases SeasonPassPurchase[]
savedFilters SavedFilter[]
aiCalls AiCall[]
swapsAsked VolunteerSwap[] @relation("VolunteerSwapFrom")
swapsTaken VolunteerSwap[] @relation("VolunteerSwapTo")
@@ -505,6 +506,15 @@ model Registration {
bundleDiscount Float @default(0)
// Where named attendees' tickets go: all to the payer, or each to the attendee.
ticketDelivery TicketDelivery @default(payer)
// Corrections (1.52.0). The attendee form can come later: tickets don't wait for it ("Fill in
// later" at the door or in Manual registrations, or an admin in Corrections).
formWaivedAt DateTime?
formWaivedById String?
// An admin issued the tickets before it was paid ("pay later"): the balance is still owed.
ticketsReleasedAt DateTime?
ticketsReleasedById String?
// A balance an admin wrote off ("make it free"); part of discountTotal (utils/coupons.js).
complimentaryDiscount Float @default(0)
analyticsSessionId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -778,6 +788,10 @@ enum AdminAuditAction {
event_sessions_changed
attendance_marked
attendance_unmarked
attendee_form_waived
tickets_issued_override
registration_complimentary
checkin_corrected
certificate_issued
certificate_revoked
job_run_manual
@@ -820,6 +834,7 @@ enum AdminAuditAction {
season_pass_changed
webhook_endpoint_changed
registrations_bulk
ai_settings_changed
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
@@ -2315,3 +2330,21 @@ model SavedFilter {
@@unique([userId, page, name])
@@index([userId, page])
}
/// One AI helper call (2.0). The Hub meters the money; this copy is for Site settings → AI (use
/// per person, per helper and per day) and per-person daily limits. No prompt or answer text is
/// kept. `userId` is cleared when the account is anonymised; rows go after 13 months.
model AiCall {
id String @id @default(uuid())
userId String?
helper String
// ok, refused, incomplete, failed, cap_reached, unavailable
outcome String
// What the church was charged, in cents (fractions of a cent).
chargedCents Float @default(0)
createdAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([createdAt])
@@index([userId, createdAt])
}
+171
View File
@@ -0,0 +1,171 @@
/**
* The AI helpers' eval set (roadmap §15): `npm run ai:eval` in backend/.
*
* Runs fixed examples of all five helpers through this site's real Hub connection (so the site must
* be connected and its licence must have an AI allowance), checks the facts that matter
* automatically, and prints every answer for a person to read. Run it after changing a helper's
* instructions or the model on the Hub, and compare with the last run. It costs about R1 a run
* with Claude Sonnet 5.5; the calls count against this site's allowance.
*
* The examples come from the October 2026 model trial; the inputs are made up and hold no
* personal information.
*/
import 'dotenv/config';
import { prisma } from '../src/v2/legacy';
import { draftEvent, summariseReport, draftMessage, summariseFeedback, improveText } from '../src/ai/helpers';
type Check = [label: string, ok: boolean];
type Case = { name: string; run: (userId: string) => Promise<{ output: unknown; checks: Check[] }> };
const has = (text: unknown, re: RegExp) => re.test(JSON.stringify(text));
const near = (n: number, target: number, give = 1) => Math.abs(n - target) <= give;
const CAMP_NOTES = 'Youth camp for grades 8–12, Fri 14 March 18:00 to Sun 16 March 14:00 at Camp Wartburg near Pietermaritzburg. 60 places. R450 per person, R400 if they book by 31 January. Parents must give consent. Theme "Rooted" (Colossians 2:7). Costs: venue R180 per person per night including meals, bus hire R6 500 return, speaker gift R1 000, T-shirts R95 each. Friday evening worship, Saturday sessions morning and evening with sport in the afternoon, Sunday morning service before we leave. Bring a sleeping bag, Bible and a torch.';
const FEEDBACK_COMMENTS = [
'The keynote on Saturday morning was exactly what I needed this year.', 'Could not hear a thing in Hall B, the sound kept cutting out.',
'Lunch queue took 40 minutes, we missed the start of the afternoon session.', 'Loved the worship, so good to be together again.',
'Breakouts were too short, just as it got deep we had to leave.', 'Check-in with the QR code was so quick, well done.',
'Parking was a nightmare, we parked on the road and walked.', 'Please have childcare next year, I almost could not come.',
'Speaker in the parenting breakout was brilliant.', 'Sound in Hall B again, the mic was crackling the whole time.',
'Food ran out of vegetarian options by the time we got there.', 'The whole day felt so well organised.',
'More time for breakouts please, and maybe fewer of them.', 'Lunch took forever.', 'Loved the gift bags!',
'Hall A was freezing, the aircon was too cold.', 'The app reminders the day before were really helpful.',
'Would love a longer Q&A with the main speaker.', 'Queue for lunch was very long, maybe stagger the breakouts?',
'Parking marshals would help a lot.', 'Worship team was amazing.', 'Childcare or a kids programme would let young moms come.',
'Some of the slides were hard to read from the back.', 'Best conference yet, thank you to the whole team.',
'I would pay a bit more for a better lunch.', 'Hall B sound please fix it.',
];
const CASES: Case[] = [
{
name: 'Draft an event — full notes with costs',
async run(userId) {
const r = await draftEvent(userId, CAMP_NOTES);
const d = r.draft;
return {
output: r,
checks: [
['price R450, early bird R400 until 31 January', d.ticketOptions[0]?.priceRand === 450 && d.ticketOptions[0]?.earlyBirdPriceRand === 400 && d.ticketOptions[0]?.earlyBirdUntil.endsWith('-01-31')],
['60 places, parental consent', d.capacity === 60 && d.needsParentalConsent],
['budget R34 800, break-even 78', r.totalCostRand === 34800 && r.breakEvenPlaces === 78],
['warns the camp is short even when full', r.warnings.some(w => w.includes('short'))],
['times not in the notes left empty (only 18:00 and 14:00 given)', d.sessions.every(s => !s.startTime || s.startTime === '18:00')],
],
};
},
},
{
name: 'Summarise a report — a month of payments',
async run(userId) {
const { summary } = await summariseReport(userId, {
reportKey: 'revenue', title: 'Revenue summary — October 2026 (September for comparison)',
columns: ['Method', 'October payments', 'October amount', 'September payments', 'September amount'],
rows: [['Card', 96, 'R42 300', 81, 'R35 100'], ['EFT', 14, 'R18 000', 17, 'R21 500'], ['Cash at the door', 31, 'R6 250', 22, 'R4 100'], ['Refunds', 4, 'R2 700', 2, 'R450']],
stats: [{ label: 'Card payments waiting for the provider over 2 days', value: '3 (R1 350)' }],
});
return {
output: summary,
checks: [
['October total R66 550', has(summary, /66[\s ]?550/)],
['up on September (R60 700)', has(summary, /60[\s ]?700|5[\s ]?850|9[.,]6/)],
['the 3 waiting card payments need checking', has(summary.watchOut, /1[\s ]?350|3 (online |card )?payments/i)],
],
};
},
},
{
name: 'Summarise feedback — women\'s conference',
async run(userId) {
const { summary } = await summariseFeedback(userId, {
event: { title: 'Women\'s Conference 2026' }, responses: 112, invited: 186,
questions: [
{ label: 'Overall', type: 'rating', answered: 112, average: 4.4 }, { label: 'Venue', type: 'rating', answered: 110, average: 3.9 },
{ label: 'Food', type: 'rating', answered: 108, average: 3.6 }, { label: 'Comments', type: 'text', answered: FEEDBACK_COMMENTS.length, texts: FEEDBACK_COMMENTS },
],
});
// A theme may be split (e.g. "lunch queues" and "vegetarian food"): add up every match.
const count = (re: RegExp) => summary.improve.filter(t => re.test(t.theme)).reduce((n, t) => n + t.mentions, 0);
return {
output: summary,
checks: [
['lunch/food raised by about 5', near(count(/lunch|food|queue/i), 5)],
['Hall B sound raised by 3', near(count(/sound|hall b/i), 3, 0)],
['quotes copied exactly', summary.quotes.every(q => FEEDBACK_COMMENTS.includes(q))],
],
};
},
},
{
name: 'Draft a message — WhatsApp reminder',
async run(userId) {
const { message } = await draftMessage(userId, { channel: 'whatsapp', note: 'Youth camp this weekend: remind them to bring a sleeping bag; the bus leaves at 7:30 on Friday from the church car park.' });
return {
output: message,
checks: [
['uses {{name}}', message.body.includes('{{name}}')],
['keeps 7:30, sleeping bag, car park', /7:30/.test(message.body) && /sleeping bag/i.test(message.body) && /car park/i.test(message.body)],
['no "reminda" (seen with Haiku)', !/reminda/i.test(message.body)],
['no event placeholders without an event', !/\{\{event\./.test(message.body)],
],
};
},
},
{
name: 'Draft a message — email about a venue change',
async run(userId) {
const { message } = await draftMessage(userId, { channel: 'email', note: 'Men\'s breakfast this Saturday is moving from the church hall to the Protea Hotel on Main Road because the hall is being renovated. Same time, 7:00. Park at the back of the hotel. Price stays R120, nothing to do if you\'ve paid. Speaker is still Pastor Mike. Questions: events@church.example.org' });
return {
output: message,
checks: [
['has a subject', message.subject.trim().length > 5],
['keeps the facts', /Protea/.test(message.body) && /7:00/.test(message.body) && /R120/.test(message.body)],
['the email address came back intact', message.body.includes('events@church.example.org')],
['no event placeholders without an event', !/\{\{event\./.test(`${message.subject} ${message.body}`)],
],
};
},
},
{
name: 'Improve this text — a messy description',
async run(userId) {
const { result } = await improveText(userId, { text: 'join us for are anual family fun day!!! theres jumping castles, food stalls and a talent show for the kids. Bring the hole family its going to be awesome. tickets R50 for adults kids under 12 free. starts 9 till 2 on the church field, if it rains we move inside to the hall. please register so we know how many boerewors rolls to make lol', mode: 'improve', where: 'description' });
return {
output: result,
checks: [
['spelling fixed', /annual/i.test(result.text) && /whole family/i.test(result.text) && !/\bare anual\b/.test(result.text)],
['every fact kept (R50, under 12, 9 to 2, hall)', /R50/.test(result.text) && /12/.test(result.text) && /\b9\b/.test(result.text) && /\b2\b/.test(result.text) && /hall/i.test(result.text)],
],
};
},
},
];
(async () => {
const admin = await prisma.user.findFirst({ where: { role: 'admin', isActive: true }, select: { id: true } });
if (!admin) throw new Error('No active admin to log the calls against.');
let passed = 0;
let total = 0;
for (const c of CASES) {
console.log(`\n━━ ${c.name}`);
try {
const { output, checks } = await c.run(admin.id);
for (const [label, ok] of checks) {
total++;
if (ok) passed++;
console.log(` ${ok ? '✓' : '✗'} ${label}`);
}
console.log(JSON.stringify(output, null, 2).split('\n').map(l => ` ${l}`).join('\n'));
} catch (e) {
total++;
console.log(` ✗ failed: ${(e as Error).message}`);
}
}
console.log(`\n${passed} of ${total} checks passed. Read the answers too: the checks only cover the facts.`);
await prisma.$disconnect();
process.exit(passed === total ? 0 : 1);
})().catch(async e => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+2 -2
View File
@@ -5,7 +5,7 @@
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') });
const { createPrismaClient } = require('../src/config/db');
const bcrypt = require('bcryptjs');
const { hashPassword } = require('../src/config/auth');
const prisma = createPrismaClient();
@@ -47,7 +47,7 @@ function randomPhone() {
}
async function main() {
const passwordHash = await bcrypt.hash('Demo@1234', 10);
const passwordHash = await hashPassword('Demo@1234');
// Role distribution: ~55 user, ~15 staff, ~5 supervisor
const rolePool = [
+2 -2
View File
@@ -42,7 +42,7 @@ const readline = require('readline');
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
const bcrypt = require('bcryptjs');
const { hashPassword } = require('../src/config/auth');
const { v4: uuidv4 } = require('uuid');
const { createPrismaClient } = require('../src/config/db');
const { applyBundles } = require('../src/utils/bundles');
@@ -1859,7 +1859,7 @@ async function main() {
return;
}
passwordHash = await bcrypt.hash(DEMO_PASSWORD, 10);
passwordHash = await hashPassword(DEMO_PASSWORD);
const people = buildPeople();
if (profile.campuses) buildCampuses();
const events = profile.upcoming.map(key => UPCOMING[key](people));
+317
View File
@@ -0,0 +1,317 @@
/**
* The five AI helpers (roadmap §15): each one's instructions, the shape of its answer, and what it
* sends. Run through `runHelper` (./index.ts), which goes via the Hub.
*
* What leaves the site (POPIA): only what a helper needs, and never names, contact details,
* medical or children's information.
* - Email addresses, phone numbers and ID numbers in any free text are swapped for placeholders
* like [[CONTACT1]] before sending, and put back in the answer (`protect` / `restore`).
* - Reports: only those without people in them (REPORTS_WITHOUT_PEOPLE).
* - Feedback: ratings, choice counts and comments, never who answered.
*
* Lessons from the October 2026 trial, built in: every helper is told today's date, and the checks
* that matter most (a weekday that doesn't match its date, an event that loses money when full)
* are worked out here in code as well, not left to the model.
*/
import { ApiError } from '../v2/errors';
import { runHelper, type AiStatus } from './index';
// ─── Dates ────────────────────────────────────────────────────────────────────
const SAST_MS = 2 * 3600 * 1000;
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
/** "Today is Friday 30 October 2026." in South African time. */
export function todayLine(now = new Date()): string {
const d = new Date(now.getTime() + SAST_MS);
return `Today is ${WEEKDAYS[d.getUTCDay()]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}.`;
}
// ─── Keeping contact details on the site ──────────────────────────────────────
const CONTACT_RE = [
/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, // email
/(?:\+27|\b0)(?:[\s-]?\d){9}\b/g, // South African phone numbers
/\b\d{13}\b/g, // South African ID numbers
];
export type Protected = { text: string; found: Map<string, string> };
/** Swap contact details for [[CONTACTn]], so they never leave the site. */
export function protect(text: string, found: Map<string, string> = new Map()): Protected {
let out = text;
for (const re of CONTACT_RE) {
out = out.replace(re, match => {
for (const [k, v] of found) if (v === match) return k;
const key = `[[CONTACT${found.size + 1}]]`;
found.set(key, match);
return key;
});
}
return { text: out, found };
}
/** Put the contact details back into an answer (strings anywhere inside it). */
export function restore<T>(value: T, found: Map<string, string>): T {
if (!found.size) return value;
const fix = (s: string) => s.replace(/\[\[CONTACT\d+\]\]/g, k => found.get(k) ?? k);
const walk = (v: unknown): unknown => (typeof v === 'string' ? fix(v) : Array.isArray(v) ? v.map(walk) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)])) : v);
return walk(value) as T;
}
const KEEP_PLACEHOLDERS = 'Text like [[CONTACT1]] stands for a contact detail the site keeps private: copy it exactly where it belongs and never change it.';
// ─── Schemas ──────────────────────────────────────────────────────────────────
const str = { type: 'string' };
const num = { type: 'number' };
const int = { type: 'integer' };
const bool = { type: 'boolean' };
const list = (items: object) => ({ type: 'array', items });
const obj = (properties: Record<string, object>, descriptions: Record<string, string> = {}) => ({
type: 'object', additionalProperties: false, required: Object.keys(properties),
properties: Object.fromEntries(Object.entries(properties).map(([k, v]) => [k, descriptions[k] ? { ...v, description: descriptions[k] } : v])),
});
// ─── 1. Draft an event ────────────────────────────────────────────────────────
export type EventDraftAnswer = {
title: string; summary: string; description: string;
startDate: string; startTime: string; endDate: string; endTime: string;
location: string; capacity: number; needsParentalConsent: boolean; ageGroup: string;
ticketOptions: { name: string; priceRand: number; description: string; earlyBirdPriceRand: number; earlyBirdUntil: string }[];
sessions: { date: string; startTime: string; endTime: string; label: string }[];
costs: { item: string; basis: string; amountRand: number }[];
checks: string[];
};
const EVENT_SCHEMA = obj({
title: str, summary: str, description: str,
startDate: str, startTime: str, endDate: str, endTime: str,
location: str, capacity: int, needsParentalConsent: bool, ageGroup: str,
ticketOptions: list(obj({ name: str, priceRand: num, description: str, earlyBirdPriceRand: num, earlyBirdUntil: str }, { earlyBirdPriceRand: '0 when there is none', earlyBirdUntil: 'YYYY-MM-DD, or empty' })),
sessions: list(obj({ date: str, startTime: str, endTime: str, label: str })),
costs: list(obj({ item: str, basis: str, amountRand: num }, { basis: 'How it is worked out, e.g. "R180 × 60 people × 2 nights"', amountRand: 'Total for this line at full capacity' })),
checks: list(str),
}, {
summary: 'One sentence for the event card',
description: 'The public event page: 2–4 short paragraphs, plain text',
startDate: 'YYYY-MM-DD', endDate: 'YYYY-MM-DD', startTime: 'HH:MM, 24-hour, or empty', endTime: 'HH:MM, 24-hour, or empty',
capacity: 'Places in total; 0 when not given',
sessions: 'Only for an event that meets on several separate dates people attend, like a weekly course: one row per date. Empty for a single event, even one over a weekend.',
checks: 'Things to look at before publishing: missing details, contradictions, money that does not add up. Empty if none.',
});
const EVENT_SYSTEM = (now: Date) => [
'You help a church events team set up an event in Cross Events, an event registration system. From the organiser\'s notes, fill in the event form.',
todayLine(now),
'- Title: short and clear. Description: for the public event page, warm and clear, 2–4 short paragraphs, plain text (no markdown): who it is for, what happens, what is included, what to bring if known.',
'- South African rand; 24-hour times. Dates as YYYY-MM-DD: the next time that date comes round after today, unless the notes give a year.',
'- If a weekday in the notes does not match its date, keep the date and say so in "checks".',
'- Ticket options: the prices the notes give, with any early-bird price and its last day. One option when only one price is given.',
'- Leave anything the notes do not give empty (or 0) rather than guess, including times.',
'- Costs: one line per cost the notes give, worked out at full capacity, showing how. Never invent costs.',
'- Never invent names, phone numbers, email addresses or links.',
KEEP_PLACEHOLDERS,
].join('\n');
/** Weekdays written next to dates in the notes ("Fri 14 March") that don't match the year used. */
export function weekdayClashes(notes: string, year: number): string[] {
const out: string[] = [];
const re = /\b(mon|tue|wed|thu|fri|sat|sun)[a-z]*\.?,?\s+(\d{1,2})(?:st|nd|rd|th)?\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*/gi;
for (const m of notes.matchAll(re)) {
const day = Number(m[2]);
const month = MONTHS.findIndex(x => x.toLowerCase().startsWith(m[3].toLowerCase()));
const actual = new Date(Date.UTC(year, month, day)).getUTCDay();
const said = WEEKDAYS.findIndex(w => w.toLowerCase().startsWith(m[1].toLowerCase()));
if (actual !== said) out.push(`The notes say ${WEEKDAYS[said]} ${day} ${MONTHS[month]}, but ${day} ${MONTHS[month]} ${year} is a ${WEEKDAYS[actual]}. Check the dates.`);
}
return out;
}
/** R34 800 — plain spaces between thousands, as the AI writes them (en-ZA uses a no-break space). */
const rands = (n: number) => `R${Math.round(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')}`;
/** Money and date checks worked out in code, whatever the model said. */
export function eventWarnings(notes: string, a: EventDraftAnswer): string[] {
const out: string[] = [];
const year = Number(a.startDate.slice(0, 4));
if (year > 2000) out.push(...weekdayClashes(notes, year));
if (a.startDate && a.endDate && `${a.endDate}T${a.endTime || '23:59'}` < `${a.startDate}T${a.startTime || '00:00'}`) out.push('The end is before the start.');
const costs = a.costs.reduce((s, c) => s + (Number(c.amountRand) || 0), 0);
const main = a.ticketOptions[0];
if (costs > 0 && main?.priceRand > 0) {
const breakEven = Math.ceil(costs / main.priceRand);
if (a.capacity > 0 && breakEven > a.capacity) {
const full = a.capacity * main.priceRand;
const early = main.earlyBirdPriceRand > 0 ? a.capacity * main.earlyBirdPriceRand : null;
out.push(`The costs in the notes come to ${rands(costs)}, which needs ${breakEven} places at ${rands(main.priceRand)} — more than the ${a.capacity} available. Even full, the event takes ${rands(full)}${early ? ` (${rands(early)} if everyone books early)` : ''}, ${rands(costs - full)} short.`);
}
}
return out;
}
export async function draftEvent(userId: string, notes: string, now = new Date()): Promise<{ draft: EventDraftAnswer; warnings: string[]; totalCostRand: number; breakEvenPlaces: number | null; status: AiStatus }> {
const p = protect(notes);
const { output, status } = await runHelper('draft_event', userId, { system: EVENT_SYSTEM(now), input: p.text, schema: EVENT_SCHEMA, maxTokens: 4000, effort: 'low' });
const draft = restore(output as EventDraftAnswer, p.found);
const totalCostRand = draft.costs.reduce((s, c) => s + (Number(c.amountRand) || 0), 0);
const price = draft.ticketOptions[0]?.priceRand || 0;
return { draft, warnings: eventWarnings(notes, draft), totalCostRand, breakEvenPlaces: totalCostRand > 0 && price > 0 ? Math.ceil(totalCostRand / price) : null, status };
}
// ─── 2. Summarise a report ────────────────────────────────────────────────────
/** Reports with no people in them: only these can be summarised (keys from ReportCatalog). */
export const REPORTS_WITHOUT_PEOPLE = ['seriesComparison', 'regTypes', 'usage', 'revenue', 'regStatus', 'donations', 'funnel', 'discounts', 'cashup', 'financeReport', 'profitReport'] as const;
const MAX_ROWS = 400;
export type ReportInput = {
reportKey: string;
title: string;
subtitle?: string;
stats?: { label: string; value: string | number }[];
columns: string[];
rows: (string | number)[][];
};
export type ReportSummary = { headline: string; insights: { title: string; detail: string }[]; watchOut: string[] };
const REPORT_SCHEMA = obj({ headline: str, insights: list(obj({ title: str, detail: str })), watchOut: list(str) }, { watchOut: 'Things that need someone to act; empty if none' });
const REPORT_SYSTEM = (now: Date) => [
'You summarise a report for the leaders of a church that uses Cross Events, an event registration system. They are busy volunteers and staff, not analysts.',
todayLine(now),
'- A one-sentence headline, then 3–5 insights: trends, changes against earlier periods, outliers, and anything that needs action.',
'- Use the report\'s numbers; work out percentages and differences where they help, and get them right. Never invent figures or causes; when you suggest a cause, say it is only a possibility.',
'- Mind the dates against today: an event that has already happened can\'t still be promoted or paid for before it starts.',
'- Plain, friendly South African English. Amounts in rand like R18 900.',
].join('\n');
/** The report as compact text: stats, then the table as pipe-separated rows. */
export function reportText(r: ReportInput): string {
const lines = [`Report: ${r.title}`];
if (r.subtitle) lines.push(r.subtitle);
for (const s of r.stats || []) lines.push(`${s.label}: ${s.value}`);
lines.push(`Columns: ${r.columns.join(' | ')}`);
for (const row of r.rows.slice(0, MAX_ROWS)) lines.push(row.map(c => String(c ?? '').replace(/\s+/g, ' ')).join(' | '));
if (r.rows.length > MAX_ROWS) lines.push(`(${r.rows.length - MAX_ROWS} more rows not shown)`);
return lines.join('\n');
}
export async function summariseReport(userId: string, report: ReportInput, now = new Date()): Promise<{ summary: ReportSummary; status: AiStatus }> {
if (!(REPORTS_WITHOUT_PEOPLE as readonly string[]).includes(report.reportKey)) {
throw Object.assign(new ApiError('FORBIDDEN', 'This report lists people, so it can\'t be sent to the AI. Summaries work on totals-only reports.'), { reason: 'AI_REPORT_HAS_PEOPLE' });
}
const p = protect(reportText(report));
const { output, status } = await runHelper('summarise_report', userId, { system: REPORT_SYSTEM(now), input: p.text, schema: REPORT_SCHEMA, maxTokens: 2000, effort: 'low' });
return { summary: restore(output as ReportSummary, p.found), status };
}
// ─── 3. Draft a message ───────────────────────────────────────────────────────
export type MessageAnswer = { subject: string; body: string };
const MESSAGE_SCHEMA = obj({ subject: str, body: str }, { subject: 'Empty for WhatsApp' });
const MESSAGE_SYSTEM = (channel: 'email' | 'whatsapp', now: Date, withEvent: boolean) => [
'You write messages from a church events team to people registered for an event, from the organiser\'s short note.',
todayLine(now),
channel === 'whatsapp'
? '- WhatsApp: plain text, at most 3 short sentences, at most one emoji. "subject" is empty.'
: '- Email: a clear subject, then the body in short plain-text paragraphs, the most important thing first. Start with "Hi {{name}}," and end with "The events team".',
withEvent
? '- Use {{name}} for the person\'s first name. {{event.title}}, {{event.start}} and {{event.link}} are filled in with the event\'s details; use them only where the message needs them, and don\'t add a separate block of event details.'
: '- Use {{name}} for the person\'s first name. Use no other {{…}} placeholders: name the event as the note does.',
'- Use only the facts in the note and the event details; never invent times, places, prices, links or contact details, or add advice the note doesn\'t give.',
'- Spell carefully: this goes to everyone registered.',
KEEP_PLACEHOLDERS,
].join('\n');
export type EventFacts = { title: string; start: string; location: string | null };
export async function draftMessage(userId: string, input: { channel: 'email' | 'whatsapp'; note: string; event?: EventFacts | null }, now = new Date()): Promise<{ message: MessageAnswer; status: AiStatus }> {
const p = protect(input.note);
const facts = input.event ? `\n\nEvent: ${input.event.title}, ${input.event.start}${input.event.location ? `, at ${input.event.location}` : ''}` : '';
const { output, status } = await runHelper('draft_message', userId, {
system: MESSAGE_SYSTEM(input.channel, now, !!input.event), input: `Note: ${p.text}${facts}`, schema: MESSAGE_SCHEMA, maxTokens: input.channel === 'whatsapp' ? 600 : 1500, effort: 'low',
});
const message = restore(output as MessageAnswer, p.found);
return { message: input.channel === 'whatsapp' ? { subject: '', body: message.body } : message, status };
}
// ─── 4. Summarise feedback ────────────────────────────────────────────────────
export type FeedbackQuestion = { label: string; type: string; answered: number; average?: number | null; counts?: { label: string; value: number }[]; texts?: string[] };
export type FeedbackSummary = { overall: string; liked: { theme: string; mentions: number }[]; improve: { theme: string; mentions: number }[]; quotes: string[]; nextTime: string[] };
const FEEDBACK_SCHEMA = obj({
overall: str, liked: list(obj({ theme: str, mentions: int })), improve: list(obj({ theme: str, mentions: int })), quotes: list(str), nextTime: list(str),
}, { liked: 'Most-mentioned first', improve: 'Most-mentioned first', quotes: 'Up to 3 short comments, copied exactly' });
const FEEDBACK_SYSTEM = [
'You summarise the feedback people gave after a church event, for the team that ran it.',
'- "overall": two sentences on how it went, using the ratings.',
'- "liked" and "improve": the main themes, most-mentioned first, each with how many comments raised it. Count carefully: a comment counts once per theme.',
'- "quotes": up to 3 short comments that capture the feedback well, copied exactly.',
'- "nextTime": 2–4 concrete suggestions for next time, based only on the feedback.',
'- The comments are anonymous; never guess who wrote one.',
KEEP_PLACEHOLDERS,
].join('\n');
const MAX_COMMENTS = 400;
const MAX_COMMENT = 600;
/** The feedback as text: each question with its average or counts, and the comments numbered. */
export function feedbackText(event: { title: string }, responses: number, invited: number, questions: FeedbackQuestion[]): string {
const lines = [`Event: ${event.title}`, `Responses: ${responses}${invited ? ` of ${invited} asked` : ''}`];
let n = 0;
for (const q of questions) {
if (q.average != null) lines.push(`${q.label} — average ${q.average} (${q.answered} answers)`);
else if (q.counts) lines.push(`${q.label} — ${q.counts.map(c => `${c.label}: ${c.value}`).join(', ')}`);
else if (q.texts?.length) {
lines.push(`${q.label} — comments:`);
for (const t of q.texts) {
if (n++ >= MAX_COMMENTS) break;
lines.push(`${n}. ${t.replace(/\s+/g, ' ').slice(0, MAX_COMMENT)}`);
}
}
}
return lines.join('\n');
}
export async function summariseFeedback(userId: string, input: { event: { title: string }; responses: number; invited: number; questions: FeedbackQuestion[] }): Promise<{ summary: FeedbackSummary; status: AiStatus }> {
const p = protect(feedbackText(input.event, input.responses, input.invited, input.questions));
const { output, status } = await runHelper('summarise_feedback', userId, { system: FEEDBACK_SYSTEM, input: p.text, schema: FEEDBACK_SCHEMA, maxTokens: 2000, effort: 'low' });
return { summary: restore(output as FeedbackSummary, p.found), status };
}
// ─── 5. Improve this text ─────────────────────────────────────────────────────
export const IMPROVE_MODES = { improve: 'Make it clear and friendly', shorter: 'Make it shorter', fix: 'Only fix spelling and grammar' } as const;
export type ImproveMode = keyof typeof IMPROVE_MODES;
export type ImproveAnswer = { text: string; changes: string[] };
const IMPROVE_SCHEMA = obj({ text: str, changes: list(str) }, { changes: 'A short list of what changed and why' });
const IMPROVE_SYSTEM = (mode: ImproveMode, where: string) => [
`You improve text a church events team wrote for ${where}.`,
mode === 'fix'
? '- Only fix spelling, grammar and punctuation. Change nothing else.'
: mode === 'shorter'
? '- Make it noticeably shorter and easier to scan, fixing spelling and grammar too.'
: '- Fix spelling, grammar and punctuation, and make it clear and friendly, about the same length or shorter.',
'- Keep every fact (times, dates, prices, places, ages) exactly; add none.',
'- Plain text, no markdown. Keep the writer\'s warmth, and any humour that works in public. Keep placeholders like {{name}} exactly.',
KEEP_PLACEHOLDERS,
].join('\n');
const WHERE = { description: 'an event page', banner: 'a short banner across the top of the website', message: 'a message to people registered for an event' } as const;
export type ImproveWhere = keyof typeof WHERE;
export async function improveText(userId: string, input: { text: string; mode: ImproveMode; where: ImproveWhere }): Promise<{ result: ImproveAnswer; status: AiStatus }> {
const p = protect(input.text);
const { output, status } = await runHelper('improve_text', userId, { system: IMPROVE_SYSTEM(input.mode, WHERE[input.where]), input: p.text, schema: IMPROVE_SCHEMA, maxTokens: Math.min(8000, 600 + Math.ceil(input.text.length / 2)), effort: 'low' });
return { result: restore(output as ImproveAnswer, p.found), status };
}
+263
View File
@@ -0,0 +1,263 @@
/**
* AI helpers (2.0, roadmap §15). The site never holds a Claude API key: every call goes through
* the Cross Events Hub (docs/hub-protocol.md §3b), which checks the licence's monthly cap in rand,
* calls Claude and meters the call. This module:
* - works out what the AI buttons do (`aiStatus`): hidden, working, or disabled until the
* allowance renews — from the licence token first, then the Hub (cached for a minute);
* - runs one helper call (`runHelper`), with this site's own switches per helper and an optional
* daily limit per person, and logs it in `AiCall` (no prompt or answer text);
* - emails the site admins once a month when 80% of the allowance is used.
* The helpers themselves (prompts, schemas, what they send) are in ./helpers.ts.
*/
import { ApiError } from '../v2/errors';
import { prisma } from '../v2/legacy';
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS Hub client and helpers aren't typed */
const hub = require('../hub/client') as {
PROTOCOL_VERSION: number;
signedPost(path: string, body: object, opts?: { timeoutMs?: number }): Promise<any>;
HubError: new (...args: any[]) => Error & { code: string; statusCode: number; data: any };
};
const { licenceStatus } = require('../hub/licence') as {
licenceStatus(): Promise<{ connected: boolean; claims: { limits?: { aiMonthlyCapCents?: number } } | null; state: string; readOnly: boolean }>;
};
const email = require('../utils/email') as {
sendMail(m: { to: string; subject: string; html: string; text: string }): Promise<unknown>;
emailWrapper(body: string): string;
isSmtpConfigured(): boolean;
};
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<unknown> };
export const HELPERS = ['draft_event', 'summarise_report', 'draft_message', 'summarise_feedback', 'improve_text'] as const;
export type HelperName = (typeof HELPERS)[number];
/** What the Hub says (§3b). Cents are charged rand, with fractions. */
export type HubAiStatus = { enabled: boolean; capReached: boolean; capCents: number; usedCents: number; usedPercent: number; renewsOn: string | null };
/** What the buttons need: the Hub's answer, whether it could be reached, and this site's switches. */
export type AiStatus = HubAiStatus & { available: boolean; helpers: Record<HelperName, boolean> };
export type AiSettings = { helpers: Record<HelperName, boolean>; dailyLimitPerPerson: number };
const SETTINGS_KEY = 'ai_settings';
const WARNED_KEY = 'ai_warned_month';
const STATUS_TTL_MS = 60 * 1000;
const CALL_TIMEOUT_MS = 90 * 1000;
const KEEP_CALLS_DAYS = 400;
const OFF: HubAiStatus = { enabled: false, capReached: false, capCents: 0, usedCents: 0, usedPercent: 0, renewsOn: null };
// ─── This site's settings ─────────────────────────────────────────────────────
export const DEFAULT_SETTINGS: AiSettings = { helpers: Object.fromEntries(HELPERS.map(h => [h, true])) as Record<HelperName, boolean>, dailyLimitPerPerson: 0 };
export async function getAiSettings(): Promise<AiSettings> {
const row = await prisma.appSetting.findUnique({ where: { key: SETTINGS_KEY } });
let saved: Partial<AiSettings> = {};
try { saved = row ? JSON.parse(row.value) : {}; } catch { /* keep the defaults */ }
return {
helpers: { ...DEFAULT_SETTINGS.helpers, ...Object.fromEntries(HELPERS.filter(h => typeof saved.helpers?.[h] === 'boolean').map(h => [h, saved.helpers![h]])) },
dailyLimitPerPerson: Number.isInteger(saved.dailyLimitPerPerson) && saved.dailyLimitPerPerson! >= 0 ? saved.dailyLimitPerPerson! : 0,
};
}
export async function saveAiSettings(next: { helpers?: Partial<Record<HelperName, boolean | undefined>>; dailyLimitPerPerson?: number }, actor: { id: string; role: string }, ip?: string): Promise<AiSettings> {
const current = await getAiSettings();
const changed = Object.fromEntries(HELPERS.filter(h => typeof next.helpers?.[h] === 'boolean').map(h => [h, next.helpers![h]]));
const value: AiSettings = {
helpers: { ...current.helpers, ...changed },
dailyLimitPerPerson: next.dailyLimitPerPerson ?? current.dailyLimitPerPerson,
};
await prisma.appSetting.upsert({ where: { key: SETTINGS_KEY }, update: { value: JSON.stringify(value) }, create: { key: SETTINGS_KEY, value: JSON.stringify(value) } });
await logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'ai_settings_changed', targetType: 'setting', targetId: SETTINGS_KEY, metadata: value, ip });
return value;
}
// ─── Status ───────────────────────────────────────────────────────────────────
let cached: { at: number; value: HubAiStatus; available: boolean } | null = null;
/** Keep what the Hub just said (every AI reply and error carries it). */
function remember(value: unknown, available = true): void {
const v = value as Partial<HubAiStatus> | null;
if (v && typeof v.enabled === 'boolean') cached = { at: Date.now(), value: { ...OFF, ...v } as HubAiStatus, available };
}
export function clearAiStatusCache(): void {
cached = null;
}
/**
* The cap from the licence token (refreshed by every heartbeat): no Hub call is needed to know AI
* is off, which is most sites.
*/
async function licenceCap(): Promise<number> {
const l = await licenceStatus();
if (!l.connected || !l.claims || l.readOnly) return 0;
return Number(l.claims.limits?.aiMonthlyCapCents) || 0;
}
async function hubStatus(fresh = false): Promise<{ value: HubAiStatus; available: boolean }> {
if (!fresh && cached && Date.now() - cached.at < STATUS_TTL_MS) return cached;
try {
remember(await hub.signedPost('/api/installs/ai/status', { protocolVersion: hub.PROTOCOL_VERSION }));
} catch (e) {
const err = e as { code?: string; statusCode?: number };
// A Hub before 1.6.0 has no AI; a released install has no licence: both mean off.
if (err.statusCode === 404) cached = { at: Date.now(), value: OFF, available: true };
else {
console.warn('[ai] could not ask the Hub for the AI status:', (e as Error).message);
// Unreachable: keep showing the buttons (they say AI is unavailable when pressed).
cached = { at: Date.now(), value: { ...(cached?.value || OFF), enabled: true }, available: false };
}
}
return cached!;
}
export async function aiStatus({ fresh = false } = {}): Promise<AiStatus> {
const settings = await getAiSettings();
if ((await licenceCap()) <= 0) return { ...OFF, available: true, helpers: settings.helpers };
const { value, available } = await hubStatus(fresh);
return { ...value, available, helpers: settings.helpers };
}
// ─── Running a helper ─────────────────────────────────────────────────────────
export type HelperRequest = {
/** The helper's fixed instructions (the Hub has Claude cache them). */
system: string;
/** This request's text. Never names, contact details, medical or children's information. */
input: string;
/** A JSON schema: the answer comes back as JSON in this shape. */
schema?: object;
maxTokens: number;
effort?: 'low' | 'medium' | 'high';
};
const failure = (code: 'FORBIDDEN' | 'RATE_LIMITED' | 'UNAVAILABLE', reason: string, message: string) => Object.assign(new ApiError(code, message), { reason });
function renewsText(renewsOn: string | null): string {
if (!renewsOn) return 'next month';
return new Date(`${renewsOn}T12:00:00Z`).toLocaleDateString('en-ZA', { day: 'numeric', month: 'long', timeZone: 'Africa/Johannesburg' });
}
/** The Hub's errors (§3b) as v2 errors, and what to log. */
function fromHubError(e: { code?: string; statusCode?: number; message: string; data?: any }): { error: ApiError; outcome: string } {
remember(e.data?.status);
switch (e.code) {
case 'AI_CAP_REACHED':
return { outcome: 'cap_reached', error: failure('FORBIDDEN', 'AI_CAP_REACHED', `This month's AI allowance is used up — it renews on ${renewsText(e.data?.status?.renewsOn ?? null)}.`) };
case 'AI_OFF': case 'LICENCE_INACTIVE': case 'AI_NOT_CONFIGURED':
return { outcome: 'unavailable', error: failure('FORBIDDEN', 'AI_OFF', 'AI helpers are not available on this site.') };
case 'AI_REFUSED':
return { outcome: 'refused', error: failure('UNAVAILABLE', 'AI_REFUSED', "The AI couldn't help with this one. Try wording it differently.") };
case 'AI_INCOMPLETE':
return { outcome: 'incomplete', error: failure('UNAVAILABLE', 'AI_INCOMPLETE', 'The answer was too long and got cut off. Try asking for less.') };
case 'AI_INPUT_TOO_LARGE':
return { outcome: 'failed', error: failure('UNAVAILABLE', 'AI_INPUT_TOO_LARGE', 'There is too much here to send in one go.') };
case 'AI_FAILED':
return { outcome: 'failed', error: failure('UNAVAILABLE', 'AI_FAILED', "The AI's answer couldn't be used. Try again.") };
default:
return { outcome: 'unavailable', error: failure('UNAVAILABLE', 'AI_UNAVAILABLE', 'AI is unavailable right now. Try again in a few minutes.') };
}
}
/** Starts of today in South African time, for the per-person daily limit. */
function startOfToday(now = new Date()): Date {
const sast = new Date(now.getTime() + 2 * 3600 * 1000);
return new Date(Date.UTC(sast.getUTCFullYear(), sast.getUTCMonth(), sast.getUTCDate()) - 2 * 3600 * 1000);
}
let prunedOn = '';
/** Calls older than about 13 months go (once a day, on the first call). */
async function pruneOldCalls(now = new Date()): Promise<void> {
const today = now.toISOString().slice(0, 10);
if (prunedOn === today) return;
prunedOn = today;
await prisma.aiCall.deleteMany({ where: { createdAt: { lt: new Date(now.getTime() - KEEP_CALLS_DAYS * 86400000) } } }).catch(() => {});
}
/** At 80% of the allowance, the site's admins get one email a month. */
async function warnAt80(status: HubAiStatus): Promise<void> {
if (!status.enabled || status.usedPercent < 80 || !status.renewsOn) return;
const row = await prisma.appSetting.findUnique({ where: { key: WARNED_KEY } });
if (row?.value === status.renewsOn) return;
await prisma.appSetting.upsert({ where: { key: WARNED_KEY }, update: { value: status.renewsOn }, create: { key: WARNED_KEY, value: status.renewsOn } });
if (!email.isSmtpConfigured()) return;
const admins = await prisma.user.findMany({ where: { role: 'admin', isActive: true }, select: { email: true } });
const when = renewsText(status.renewsOn);
const text = `This site has used ${status.usedPercent}% of this month's AI allowance (R${(status.usedCents / 100).toFixed(2)} of R${(status.capCents / 100).toFixed(2)}). The AI buttons stop working when it runs out, until it renews on ${when}. Site settings → AI shows who used it and for what.`;
for (const a of admins) {
await email.sendMail({ to: a.email, subject: `AI allowance ${status.usedPercent}% used`, text, html: email.emailWrapper(`<p>${text}</p>`) })
.catch(e => console.warn('[ai] 80% warning email failed:', (e as Error).message));
}
}
/**
* Run one helper call for a person. Resolves to the answer (text, or JSON when a schema was
* given) and the new status; throws an ApiError with a `reason` (AI_OFF, AI_HELPER_OFF,
* AI_CAP_REACHED, AI_DAILY_LIMIT, AI_REFUSED, AI_INCOMPLETE, AI_UNAVAILABLE…).
*/
export async function runHelper(helper: HelperName, userId: string, req: HelperRequest): Promise<{ output: unknown; status: AiStatus }> {
const status = await aiStatus();
if (!status.enabled) throw failure('FORBIDDEN', 'AI_OFF', 'AI helpers are not available on this site.');
if (!status.helpers[helper]) throw failure('FORBIDDEN', 'AI_HELPER_OFF', 'This AI helper is switched off in Site settings → AI.');
if (status.capReached) throw failure('FORBIDDEN', 'AI_CAP_REACHED', `This month's AI allowance is used up — it renews on ${renewsText(status.renewsOn)}.`);
const { dailyLimitPerPerson } = await getAiSettings();
if (dailyLimitPerPerson > 0) {
const today = await prisma.aiCall.count({ where: { userId, createdAt: { gte: startOfToday() }, outcome: { in: ['ok', 'refused', 'incomplete'] } } });
if (today >= dailyLimitPerPerson) throw failure('RATE_LIMITED', 'AI_DAILY_LIMIT', `You've used the AI helpers ${dailyLimitPerPerson} times today, the most this site allows. Try again tomorrow.`);
}
let outcome = 'ok';
let chargedCents = 0;
try {
const reply = await hub.signedPost('/api/installs/ai/complete', { protocolVersion: hub.PROTOCOL_VERSION, helper, ...req }, { timeoutMs: CALL_TIMEOUT_MS });
chargedCents = Number(reply.chargedCents) || 0;
remember(reply.status);
await warnAt80(reply.status).catch(e => console.warn('[ai] 80% check failed:', (e as Error).message));
return { output: reply.output, status: { ...(cached?.value || OFF), available: true, helpers: status.helpers } };
} catch (e) {
if (e instanceof ApiError) throw e;
const mapped = fromHubError(e as Error & { code?: string; data?: any });
outcome = mapped.outcome;
if ((e as { code?: string }).code === 'HUB_UNREACHABLE') remember(cached?.value || { ...OFF, enabled: true }, false);
throw mapped.error;
} finally {
await prisma.aiCall.create({ data: { userId, helper, outcome, chargedCents } }).catch(err => console.warn('[ai] could not log the call:', (err as Error).message));
await pruneOldCalls();
}
}
// ─── Usage (Site settings → AI) ───────────────────────────────────────────────
/** This month's use on this site: per person, per helper and per day (South African time). */
export async function usageThisMonth(now = new Date()) {
const sast = new Date(now.getTime() + 2 * 3600 * 1000);
const from = new Date(Date.UTC(sast.getUTCFullYear(), sast.getUTCMonth(), 1) - 2 * 3600 * 1000);
const calls = await prisma.aiCall.findMany({ where: { createdAt: { gte: from } }, select: { userId: true, helper: true, outcome: true, chargedCents: true, createdAt: true } });
const add = (m: Map<string, { uses: number; chargedCents: number }>, key: string, cents: number) => {
const v = m.get(key) || { uses: 0, chargedCents: 0 };
v.uses++;
v.chargedCents += cents;
m.set(key, v);
};
const byPerson = new Map<string, { uses: number; chargedCents: number }>();
const byHelper = new Map<string, { uses: number; chargedCents: number }>();
const byDay = new Map<string, { uses: number; chargedCents: number }>();
for (const c of calls) {
if (c.outcome === 'cap_reached' || c.outcome === 'unavailable') continue;
add(byPerson, c.userId || '', c.chargedCents);
add(byHelper, c.helper, c.chargedCents);
add(byDay, new Date(c.createdAt.getTime() + 2 * 3600 * 1000).toISOString().slice(0, 10), c.chargedCents);
}
const people = await prisma.user.findMany({ where: { id: { in: [...byPerson.keys()].filter(Boolean) } }, select: { id: true, name: true } });
const names = new Map(people.map(p => [p.id, p.name]));
return {
people: [...byPerson.entries()].map(([id, v]) => ({ userId: id || null, name: id ? names.get(id) || 'Unknown' : 'Removed account', ...v })).sort((a, b) => b.chargedCents - a.chargedCents),
helpers: [...byHelper.entries()].map(([helper, v]) => ({ helper, ...v })).sort((a, b) => b.chargedCents - a.chargedCents),
days: [...byDay.entries()].map(([day, v]) => ({ day, ...v })).sort((a, b) => a.day.localeCompare(b.day)),
};
}
// For tests.
export const _internal = { fromHubError, startOfToday, renewsText };
+15 -1375
View File
File diff suppressed because it is too large Load Diff
+22 -7
View File
@@ -1,4 +1,6 @@
const jwt = require('jsonwebtoken');
const argon2 = require('@node-rs/argon2');
// Only for checking passwords saved before 2.0 (they're re-hashed with Argon2id at the next sign-in).
const bcrypt = require('bcryptjs');
// `extra` adds claims, e.g. { setup: true } for the first-run setup wizard's token.
@@ -10,17 +12,30 @@ const generateToken = (userId, role, tokenVersion = 0, extra = {}) => {
);
};
const hashPassword = async (password) => {
const salt = await bcrypt.genSalt(10);
return await bcrypt.hash(password, salt);
// Argon2id with OWASP's recommended minimum (19 MiB, 2 passes, 1 lane): about 20–40 ms a hash.
const ARGON2 = { algorithm: 2 /* Argon2id (a const enum in the typings, so not on the module) */, memoryCost: 19456, timeCost: 2, parallelism: 1 };
const ARGON2_PREFIX = `$argon2id$v=19$m=${ARGON2.memoryCost},t=${ARGON2.timeCost},p=${ARGON2.parallelism}$`;
const hashPassword = async (password) => argon2.hash(password, ARGON2);
/** Whether `entered` is the password behind `stored` (an Argon2 hash, or a bcrypt one from before 2.0). */
const comparePassword = async (enteredPassword, storedPassword) => {
if (typeof enteredPassword !== 'string' || typeof storedPassword !== 'string') return false;
try {
if (storedPassword.startsWith('$argon2')) return await argon2.verify(storedPassword, enteredPassword);
if (/^\$2[aby]\$/.test(storedPassword)) return await bcrypt.compare(enteredPassword, storedPassword);
} catch {
// A malformed hash never matches.
}
return false;
};
const comparePassword = async (enteredPassword, storedPassword) => {
return await bcrypt.compare(enteredPassword, storedPassword);
};
/** True for a hash that isn't Argon2id with today's settings (bcrypt, or weaker Argon2). */
const needsRehash = (storedPassword) => typeof storedPassword === 'string' && !storedPassword.startsWith(ARGON2_PREFIX);
module.exports = {
generateToken,
hashPassword,
comparePassword,
};
needsRehash,
};
+6 -2
View File
@@ -1,6 +1,6 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { generateTicketsForRegistration, formsOutstanding } = require('../utils/ticketUtils');
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
const { computeDonationRemaining } = require('../utils/donationUtils');
const { logAdminAction } = require('../utils/adminAudit');
@@ -238,7 +238,9 @@ const createPayment = async (req, res) => {
// If it's a registration payment, update registration status
let updatedRegistration = null;
let generatedTickets = [];
// Attendee forms still holding the tickets back (the door offers them, or "Fill in later").
let formsNeeded = 0;
if (registrationId) {
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
@@ -284,6 +286,7 @@ const createPayment = async (req, res) => {
} catch (error) {
console.error('Error generating tickets:', error);
}
formsNeeded = await formsOutstanding(registrationId);
}
}
@@ -318,6 +321,7 @@ const createPayment = async (req, res) => {
...updatedRegistration
},
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined,
formsNeeded,
donationSplit: donationSplit || undefined
};
res.status(201).json(paymentWithUpdatedRegistration);
+41 -44
View File
@@ -539,55 +539,52 @@ const requestPasswordReset = async (req, res) => {
const user = await prisma.user.findUnique({ where: { email } });
// Explicitly check existence as requested
if (!user) {
return res.status(404).json({ message: 'Email not found' });
}
// Create token valid for 1 hour
const token = uuidv4();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
// If PasswordReset model isn't available (migration not run), return clear error
if (!prisma.passwordReset || typeof prisma.passwordReset.create !== 'function' || typeof prisma.passwordReset.updateMany !== 'function') {
console.warn('[PasswordReset] Prisma model not available. Run Prisma migrations to enable password reset tokens.');
return res.status(500).json({ message: 'Password reset is not available. Please contact support.' });
}
// Invalidate previous tokens (optional)
await prisma.passwordReset.updateMany({
where: { userId: user.id, used: false, expiresAt: { gt: new Date() } },
data: { used: true }
});
await prisma.passwordReset.create({
data: {
id: uuidv4(),
userId: user.id,
token,
expiresAt,
used: false
}
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`;
const { sendMail, buildPasswordResetEmail } = require('../utils/email');
const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl });
// Security: always email
sendMail({ to: user.email, subject: 'Reset your password', ...emailContent })
.catch(e => console.warn('[password reset email] Failed:', e?.message || e));
// Also WhatsApp if preferred (security message — sent in addition to email)
const { waText } = require('../utils/notify');
waText(user, emailContent.text).catch(() => {});
return res.json({ message: 'If that email exists, a password reset link has been sent.' });
// Security (1.51.17): the same answer, with the same status, whether or not an account has
// this email — and it's sent before any work that only happens for real accounts, so the
// response time doesn't tell either. The link is made and sent afterwards.
res.json({ message: GENERIC_RESET_MESSAGE });
if (user) sendPasswordResetLink(user).catch(e => console.warn('[password reset] Failed:', e?.message || e));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
const GENERIC_RESET_MESSAGE = 'If an account uses that email, we\'ve sent it a link to reset the password.';
/** A one-hour reset link for this account, by email (and WhatsApp when they prefer it). */
async function sendPasswordResetLink(user) {
const token = uuidv4();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
// Invalidate previous tokens (optional)
await prisma.passwordReset.updateMany({
where: { userId: user.id, used: false, expiresAt: { gt: new Date() } },
data: { used: true }
});
await prisma.passwordReset.create({
data: {
id: uuidv4(),
userId: user.id,
token,
expiresAt,
used: false
}
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`;
const { sendMail, buildPasswordResetEmail } = require('../utils/email');
const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl });
// Security: always email
sendMail({ to: user.email, subject: 'Reset your password', ...emailContent })
.catch(e => console.warn('[password reset email] Failed:', e?.message || e));
// Also WhatsApp if preferred (security message — sent in addition to email)
const { waText } = require('../utils/notify');
waText(user, emailContent.text).catch(() => {});
}
// @desc Reset password using token
// @route POST /api/users/reset
// @access Public
@@ -5,16 +5,6 @@ const { handleProviderWebhook } = require('../payments/webhook');
// @access Public
const handleYocoWebhook = handleProviderWebhook('yoco');
const handleWhatsappWebhook = async (req, res) => {
try {
const { body } = req;
const { message } = body;
} catch (error) {
res.status(500).json({ error: 'Internal Server Error' });
}
}
module.exports = {
handleYocoWebhook,
handleWhatsappWebhook,
};
+13 -8
View File
@@ -25,10 +25,12 @@ function hubUrl() {
}
class HubError extends Error {
constructor(message, code, status) {
constructor(message, code, status, data = null) {
super(message);
this.code = code;
this.statusCode = status;
// The Hub's whole error body (AI errors carry the new AI status, protocol §3b).
this.data = data;
}
}
@@ -53,7 +55,7 @@ async function read(keys) {
return Object.fromEntries(rows.map(r => [r.key, r.value]));
}
async function post(path, body, headers = {}) {
async function post(path, body, headers = {}, timeoutMs = TIMEOUT_MS) {
const raw = JSON.stringify(body);
let res;
try {
@@ -61,18 +63,21 @@ async function post(path, body, headers = {}) {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(typeof headers === 'function' ? headers(raw) : headers) },
body: raw,
signal: AbortSignal.timeout(TIMEOUT_MS),
signal: AbortSignal.timeout(timeoutMs),
});
} 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);
if (!res.ok) throw new HubError(data.message || `The Hub answered ${res.status}`, data.code || 'HUB_ERROR', res.status, data);
return data;
}
/** POST signed with the install secret (§3). Throws NOT_CONNECTED when there is no connection. */
async function signedPost(path, body) {
/**
* POST signed with the install secret (§3). Throws NOT_CONNECTED when there is no connection.
* AI calls wait longer than the default (Claude can take a while to write).
*/
async function signedPost(path, body, { timeoutMs = TIMEOUT_MS } = {}) {
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);
@@ -83,7 +88,7 @@ async function signedPost(path, body) {
'X-Hub-Timestamp': timestamp,
'X-Hub-Signature': crypto.createHmac('sha256', secret).update(`${timestamp}.${raw}`).digest('hex'),
};
});
}, timeoutMs);
}
/** Register this install with a licence key. Returns the licence claims. */
@@ -211,4 +216,4 @@ function heartbeatSoon(delayMs = 30 * 1000) {
soonTimer.unref?.();
}
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat, heartbeatSoon, requestUpdate, cancelUpdateRequest, snoozeUpdate };
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, signedPost, connect, heartbeat, heartbeatSoon, requestUpdate, cancelUpdateRequest, snoozeUpdate };
+25 -27
View File
@@ -10,34 +10,32 @@
const { licenceStatus } = require('./licence');
// /api/v2 only: 2.0 removed v1 (1.52.3 added these — the screens had used v2 since 1.51.15).
const BLOCKED = [
['POST', /^\/api\/events\/?$/],
['PUT', /^\/api\/events\/[^/]+\/?$/],
['POST', /^\/api\/events\/[^/]+\/copy-from\/?$/],
['POST', /^\/api\/series\/?$/],
['PUT', /^\/api\/series\/[^/]+\/?$/],
// Season passes: new or changed passes, and buying one (it makes registrations and payments).
['POST', /^\/api\/season-passes\/?$/],
['PUT', /^\/api\/season-passes\/[^/]+\/?$/],
['POST', /^\/api\/season-passes\/[^/]+\/buy\/?$/],
['POST', /^\/api\/season-passes\/purchases\/[^/]+\/checkout\/?$/],
['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\/?$/],
['POST', /^\/api\/v2\/events\/?$/],
['PATCH', /^\/api\/v2\/events\/[^/]+\/?$/],
['POST', /^\/api\/v2\/events\/[^/]+\/copy-from\/?$/],
['POST', /^\/api\/v2\/series\/?$/],
['PATCH', /^\/api\/v2\/series\/[^/]+\/?$/],
['POST', /^\/api\/v2\/season-passes\/?$/],
['PATCH', /^\/api\/v2\/season-passes\/[^/]+\/?$/],
['POST', /^\/api\/v2\/season-passes\/[^/]+\/buy\/?$/],
['POST', /^\/api\/v2\/season-pass-purchases\/[^/]+\/checkout\/?$/],
['POST', /^\/api\/v2\/events\/[^/]+\/ticket-options\/?$/],
['PATCH', /^\/api\/v2\/ticket-options\/[^/]+\/?$/],
['POST', /^\/api\/v2\/ticket-options\/[^/]+\/variants\/?$/],
['PATCH', /^\/api\/v2\/variants\/[^/]+\/?$/],
['POST', /^\/api\/v2\/registrations\/?$/],
['POST', /^\/api\/v2\/registrations\/manual\/?$/],
['PUT', /^\/api\/v2\/registrations\/[^/]+\/items\/?$/],
['POST', /^\/api\/v2\/payments\/?$/],
['POST', /^\/api\/v2\/registrations\/[^/]+\/(checkout|payment-link(\/send)?)\/?$/],
['POST', /^\/api\/v2\/payment-links\/[^/]+\/?$/],
['POST', /^\/api\/v2\/waitlist\/?$/],
['POST', /^\/api\/v2\/coupons\/?$/],
['POST', /^\/api\/v2\/events\/[^/]+\/bundles\/?$/],
['POST', /^\/api\/v2\/volunteer-roles\/[^/]+\/(sign-up|assign)\/?$/],
['POST', /^\/api\/v2\/events\/[^/]+\/volunteer-roles\/?$/],
];
function isBlocked(method, path) {
-18
View File
@@ -1,18 +0,0 @@
const express = require('express');
const router = express.Router();
const { listAuditLog } = require('../controllers/adminAuditController');
const { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions } = require('../controllers/impersonationController');
const { listJobs, runJobNow } = require('../controllers/jobsController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/audit-log', protect, admin, listAuditLog);
router.get('/jobs', protect, admin, listJobs);
router.post('/jobs/:name/run', protect, admin, runJobNow);
router.get('/impersonation-sessions', protect, admin, listSessions);
// Called with the impersonation token itself (which belongs to the viewed user).
router.post('/impersonate/end', protect, endImpersonation);
router.post('/impersonate/log', protect, logPageView);
router.get('/impersonate/current', protect, currentImpersonation);
router.post('/impersonate/:userId', protect, admin, startImpersonation);
module.exports = router;
-23
View File
@@ -1,23 +0,0 @@
const express = require('express');
const router = express.Router();
const {
getBoard, createSet, updateSet, deleteSet, createGroup, updateGroup, deleteGroup,
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, 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;
-19
View File
@@ -1,19 +0,0 @@
const express = require('express');
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,
max: 60,
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => res.status(204).end(), // silently drop floods
});
router.post('/track', trackLimiter, trackStage);
router.get('/funnel', protect, supervisor, scopeList, getFunnel);
module.exports = router;
-26
View File
@@ -1,26 +0,0 @@
const express = require('express');
const router = express.Router();
const {
getRegister, markAttendance, exportRegister,
issueCertificates, revokeCertificate, sendCertificatesHandler, downloadEventCertificates, previewCertificate,
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, campusIndependent('previews a certificate template from the request body'), previewCertificate);
router.get('/certificates/:id/pdf', protect, downloadCertificate);
router.post('/certificates/:id/revoke', protect, supervisor, scopeEvent({ resolve: 'certificate' }), revokeCertificate);
// Attendance register
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;
-10
View File
@@ -1,10 +0,0 @@
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, scopeEvent({ from: req => req.body?.eventId }), scheduleAutomations);
module.exports = router;
-10
View File
@@ -1,10 +0,0 @@
const express = require('express');
const router = express.Router();
const { getBackups, triggerBackup, downloadBackup } = require('../controllers/backupController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/', protect, admin, getBackups);
router.post('/run', protect, admin, triggerBackup);
router.get('/:filename/download', protect, admin, downloadBackup);
module.exports = router;
-10
View File
@@ -1,10 +0,0 @@
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, unscopedOnly, setBanner);
module.exports = router;
-12
View File
@@ -1,12 +0,0 @@
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, 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;
-15
View File
@@ -1,15 +0,0 @@
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, 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;
-21
View File
@@ -1,21 +0,0 @@
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;
-16
View File
@@ -1,16 +0,0 @@
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, 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);
router.post('/lines/:lineId/pending', protect, admin, resolvePending);
module.exports = router;
-21
View File
@@ -1,21 +0,0 @@
const express = require('express');
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, 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);
router.post('/:token', consentLimiter, answerConsent);
module.exports = router;
-13
View File
@@ -1,13 +0,0 @@
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, 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);
module.exports = router;
-16
View File
@@ -1,16 +0,0 @@
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, scopeEvent({ from: req => req.body?.eventId }), validateCode);
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;
-93
View File
@@ -1,93 +0,0 @@
const express = require('express');
const router = express.Router();
const {
createEvent,
copyEventSetupFrom,
getEvents,
getAllEvents,
getEventsAll,
getEventById,
getEventIcs,
updateEvent,
getEventNotifyRecipients,
updateEventNotifyRecipients,
deleteEvent,
createEventOption,
updateEventOption,
deleteEventOption,
createOptionVariant,
updateOptionVariant,
deleteOptionVariant,
listEventAttachments,
uploadEventAttachment,
deleteEventAttachment,
attachmentsStatus,
attachmentsSync,
emailEventAttendees,
scheduleEmailEventAttendees,
whatsappEventAttendees,
scheduleWhatsappEventAttendees,
getEventByAlias
} = require('../controllers/eventController');
const { protect, admin, supervisor, optionalAuth } = require('../middleware/authMiddleware');
const { scopeEvent, scopeBody, scopeList } = require('../utils/campusScope');
// Public routes
router.get('/', getEvents);
// Staff/supervisor/admin: all events including hidden, optional past/inactive filters
router.get('/all', protect, getEventsAll);
// Admin routes
router.get('/admin/all', protect, admin, getAllEvents);
router.get('/attachments/status', protect, admin, attachmentsStatus);
router.post('/attachments/sync', protect, admin, attachmentsSync);
// Public route for single event (keep after specific admin routes).
// optionalAuth populates req.user when a valid token is present so staff/supervisor/admin
// can still load inactive events (e.g. for cashup or editing) without being 404'd.
router.get('/:id', optionalAuth, getEventById);
router.get('/:id/ics', optionalAuth, getEventIcs);
router.get('/by-alias/:redirectUrl', getEventByAlias);
// Create/Update/Delete event
router.post('/', protect, supervisor, scopeList, createEvent);
router.route('/:id')
.put(protect, supervisor, scopeEvent({ need: 'manage' }), updateEvent)
.delete(protect, admin, deleteEvent);
// Clone: copy allocation groups (and costs, for admins) from the source event into a new copy.
router.post('/:id/copy-from', protect, supervisor, scopeEvent({ need: 'manage' }), scopeBody([['fromEventId', 'event']]), copyEventSetupFrom);
// Notification recipients: who gets registration/payment/daily-summary emails for this event
router.route('/:id/notify-recipients')
.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, 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, scopeEvent(), emailEventAttendees);
// Schedule bulk email to attendees
router.post('/:id/email-attendees/schedule', protect, supervisor, scopeEvent(), scheduleEmailEventAttendees);
// Bulk WhatsApp message to attendees
router.post('/:id/whatsapp-attendees', protect, supervisor, scopeEvent(), whatsappEventAttendees);
// Schedule bulk WhatsApp message to attendees
router.post('/:id/whatsapp-attendees/schedule', protect, supervisor, scopeEvent(), scheduleWhatsappEventAttendees);
// Event options routes
router.post('/:id/options', protect, supervisor, scopeEvent({ need: 'manage' }), createEventOption);
router.route('/options/:id')
.put(protect, supervisor, scopeEvent({ resolve: 'eventOption', need: 'manage' }), updateEventOption)
.delete(protect, admin, deleteEventOption);
// Option variant routes
router.post('/options/:id/variants', protect, supervisor, scopeEvent({ resolve: 'eventOption', need: 'manage' }), createOptionVariant);
router.route('/variants/:id')
.put(protect, supervisor, scopeEvent({ resolve: 'optionVariant', need: 'manage' }), updateOptionVariant)
.delete(protect, admin, deleteOptionVariant);
module.exports = router;
-15
View File
@@ -1,15 +0,0 @@
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, 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;
-10
View File
@@ -1,10 +0,0 @@
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, scopeList, listFormResponses);
module.exports = router;
-18
View File
@@ -1,18 +0,0 @@
const express = require('express');
const router = express.Router();
const { getStatus, connect, heartbeatNow, requestUpdate, cancelUpdateRequest, snoozeUpdate, getNotices } = require('../controllers/hubController');
const { protect, admin, supervisor } = require('../middleware/authMiddleware');
const { campusIndependent } = require('../utils/campusScope');
// 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);
router.post('/update-request/cancel', protect, admin, cancelUpdateRequest);
router.post('/update-snooze', protect, admin, snoozeUpdate);
// The provider's notices: site admins and supervisors see them as a banner.
router.get('/notices', protect, supervisor, campusIndependent('notices from the provider are for the whole site'), getNotices);
module.exports = router;
-47
View File
@@ -1,47 +0,0 @@
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;
@@ -1,17 +0,0 @@
const express = require('express');
const router = express.Router();
const { list, create, update, remove, rotateSecret, test, deliveries, resend } = require('../controllers/outboundWebhookController');
const { protect, admin } = require('../middleware/authMiddleware');
// Outbound webhooks (1.48.0): admins only. Payment provider webhooks *into* the site are /api/webhooks/<provider>.
router.use(protect, admin);
router.get('/', list);
router.post('/', create);
router.post('/deliveries/:id/resend', resend);
router.put('/:id', update);
router.delete('/:id', remove);
router.post('/:id/rotate-secret', rotateSecret);
router.post('/:id/test', test);
router.get('/:id/deliveries', deliveries);
module.exports = router;
-10
View File
@@ -1,10 +0,0 @@
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;
-54
View File
@@ -1,54 +0,0 @@
const express = require('express');
const router = express.Router();
const {
createPayment,
getPayments,
getUserPayments,
getPaymentById,
getPaymentsByRegistration,
getPaymentsByEvent,
assignDonationToRegistration,
unassignDonationFromRegistration,
createCheckout,
sendPaymentLink,
createRefund,
getPaymentStats,
sendReceipt,
} = require('../controllers/paymentController');
const { listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider, listOpenCheckouts, checkCheckoutStatus } = require('../controllers/paymentProvidersController');
const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware');
const { scopeEvent, scopeBody, scopeList, unscopedOnly } = require('../utils/campusScope');
// Campus-scoped staff: payments follow their registration's or event's campus. Door payments for
// church-wide events are allowed; refunds of them aren't (need 'manage'); a payment or donation
// not linked to any event is for admins and unscoped supervisors (orphan 'deny').
const paymentTarget = scopeBody([['registrationId', 'registration'], ['eventId', 'event']], { orphan: 'deny' });
// Protected routes
router.post('/', protect, supervisor, 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, scopeBody([['registrationId', 'registration']]), sendPaymentLink);
// Older names, kept for existing clients
router.post('/yoco-checkout', protect, createCheckout);
router.post('/yoco-checkout/send', protect, supervisor, scopeBody([['registrationId', 'registration']]), sendPaymentLink);
router.get('/mypayments', protect, getUserPayments);
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, 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;
-87
View File
@@ -1,87 +0,0 @@
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,
markBuiltInReviewed,
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);
router.post('/notice/builtin-reviewed', protect, admin, markBuiltInReviewed);
// 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;
@@ -1,21 +0,0 @@
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, unscopedOnly, getAllProviderTransactions);
// GET all unreconciled transactions (admin/supervisor)
router.get('/unreconciled', protect, supervisor, unscopedOnly, getUnreconciledProviderTransactions);
// POST reconcile a transaction
router.post('/:id/reconcile', protect, supervisor, unscopedOnly, reconcileProviderTransaction);
// POST ignore a transaction
router.post('/:id/ignore', protect, supervisor, unscopedOnly, ignoreProviderTransaction);
module.exports = router;
-54
View File
@@ -1,54 +0,0 @@
const express = require('express');
const router = express.Router();
const {
createRegistration,
getRegistrations,
getUserRegistrations,
getRegistrationById,
updateRegistrationStatus,
cancelRegistration,
getRegistrationsByEvent,
createManualRegistration,
updateRegistrationOptions,
submitFormResponses, replaceFormResponses,
getFormDraft, saveFormDraft,
sendInvoice,
applyCoupon, removeCoupon,
updateAttendees, sendAttendeeFormLink,
} = require('../controllers/registrationController');
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
const { scopeEvent, scopeList, scopeBody } = require('../utils/campusScope');
const { notOnSeasonPass } = require('../utils/seasonPass');
// 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, scopeEvent({ resolve: 'registration', ownerOk: true }), notOnSeasonPass, 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 }), notOnSeasonPass, 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, 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, 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);
// Bulk actions (1.49.1): every ticked registration must be one this person may manage.
router.post('/bulk', protect, supervisor, scopeBody([['registrationIds', 'registration']], { orphan: 'deny' }), async (req, res) => {
try { res.json(await require('../utils/bulkRegistrations').runBulk(req, req.body || {})); } catch (e) { res.status(e.statusCode || 400).json({ message: e.message }); }
});
module.exports = router;
-18
View File
@@ -1,18 +0,0 @@
const express = require('express');
const router = express.Router();
const { generatePdf, generateExcel, emailPdf, whatsappPdf } = require('../controllers/reportController');
const { protect } = require('../middleware/authMiddleware');
// Generate and download PDF
router.post('/pdf', protect, generatePdf);
// Generate and download styled Excel (.xlsx)
router.post('/excel', protect, generateExcel);
// Email PDF to current user
router.post('/email', protect, emailPdf);
// Send PDF to current user's own WhatsApp
router.post('/whatsapp', protect, whatsappPdf);
module.exports = router;
-16
View File
@@ -1,16 +0,0 @@
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, 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;
@@ -1,12 +0,0 @@
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, scopeList, listScheduledEmails);
router.patch('/:id', protect, supervisor, scopeList, updateScheduledEmail);
router.delete('/:id', protect, supervisor, scopeList, deleteScheduledEmail);
module.exports = router;
-18
View File
@@ -1,18 +0,0 @@
const express = require('express');
const router = express.Router();
const { listPasses, createPass, updatePass, deletePass, buy, payPurchase, myPurchases } = require('../controllers/seasonPassController');
const { protect, supervisor } = require('../middleware/authMiddleware');
const { campusIndependent } = require('../utils/campusScope');
const SPANS = campusIndependent('a pass can span campuses; each event on it is checked against the person\'s campuses in the controller');
router.get('/mine', protect, myPurchases);
router.post('/:id/buy', protect, buy);
router.post('/purchases/:id/checkout', protect, payPurchase);
router.get('/', protect, supervisor, SPANS, listPasses);
router.post('/', protect, supervisor, SPANS, createPass);
router.put('/:id', protect, supervisor, SPANS, updatePass);
router.delete('/:id', protect, supervisor, SPANS, deletePass);
module.exports = router;
-17
View File
@@ -1,17 +0,0 @@
const express = require('express');
const router = express.Router();
const {
getSections,
createSection,
deleteSection,
updateSection
} = require('../controllers/sectionController');
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
const { scopeEvent, scopeList } = require('../utils/campusScope');
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, scopeEvent({ resolve: 'section', need: 'manage' }), updateSection);
module.exports = router;
-21
View File
@@ -1,21 +0,0 @@
const express = require('express');
const router = express.Router();
const { listSeries, createSeries, updateSeries, deleteSeries, getSeriesBySlug, followSeries, unfollowSeries, compare } = require('../controllers/seriesController');
const { protect, admin, supervisor, optionalAuth } = require('../middleware/authMiddleware');
const { campusIndependent, scopeList } = require('../utils/campusScope');
const SPANS = campusIndependent('a series spans campuses; adding an event to one is checked on the event');
router.get('/by-slug/:slug', optionalAuth, getSeriesBySlug);
router.post('/:id/follow', protect, followSeries);
router.delete('/:id/follow', protect, unfollowSeries);
// The list filters each series' events by the person's campuses itself.
router.get('/', protect, supervisor, SPANS, listSeries);
router.post('/', protect, supervisor, SPANS, createSeries);
router.put('/:id', protect, supervisor, SPANS, updateSeries);
router.delete('/:id', protect, admin, deleteSeries);
// Year-on-year report: only the events the person may see (filtered in the controller).
router.get('/:id/compare', protect, supervisor, scopeList, compare);
module.exports = router;
-12
View File
@@ -1,12 +0,0 @@
const express = require('express');
const router = express.Router();
const { getSettings, getAllSettings, updateSettings, needsSetup, testSmtp } = require('../controllers/settingsController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/needs-setup', needsSetup);
router.get('/all', protect, admin, getAllSettings);
router.get('/', getSettings);
router.put('/', protect, admin, updateSettings);
router.post('/test-smtp', protect, admin, testSmtp);
module.exports = router;
-12
View File
@@ -1,12 +0,0 @@
const express = require('express');
const router = express.Router();
const { setupRegister, runSetup } = require('../controllers/settingsController');
const { protect, admin } = require('../middleware/authMiddleware');
// Step 1 of setup: create the admin account, returns a JWT token
router.post('/register', setupRegister);
// Step 2 of setup: finalize settings (requires the token from /register)
router.post('/', protect, admin, runSetup);
module.exports = router;
-11
View File
@@ -1,11 +0,0 @@
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, scopeEvent(), getPool);
router.post('/checkout', optionalAuth, createSponsorCheckout);
module.exports = router;
-18
View File
@@ -1,18 +0,0 @@
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, 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, scopeList, getOverviewStats);
module.exports = router;
-55
View File
@@ -1,55 +0,0 @@
const express = require('express');
const router = express.Router();
const {
generateTickets,
getTickets,
getUserTickets,
getTicketById,
getTicketByQrCode,
getScanPreview,
scanTicket,
getTicketsByEvent,
markTicketsAsEmailSent,
emailTickets,
sendTicketsTo,
getRecentScans,
getScanStats,
transferTicketHandler,
cancelTransferHandler,
getTransferOffer,
acceptTransferOffer,
declineTransferOffer,
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, scopeEvent({ resolve: 'ticket', ownerOk: true }), getTicketById);
router.post('/email', protect, emailTickets);
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, scopeEvent({ resolve: 'ticket', ownerOk: true }), getTicketTransfers);
// Staff routes
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, scopeBody([['registrationId', 'registration']]), generateTickets);
router.get('/', protect, supervisor, scopeList, getTickets);
router.put('/email-sent', protect, admin, markTicketsAsEmailSent);
module.exports = router;
-53
View File
@@ -1,53 +0,0 @@
const express = require('express');
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, 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;
}
next();
});
}, uploadEventImage);
// Shared access rule for branding assets (logo, favicon): admin, OR allowed
// during first-time setup (no users yet).
async function brandingAssetAccess(req, res, next) {
try {
const count = await prisma.user.count();
if (count === 0) return next(); // first-time setup
return protect(req, res, () => admin(req, res, next));
} catch {
return protect(req, res, () => admin(req, res, next));
}
}
// @route POST /api/uploads/logo
// @desc Upload site logo — admin, OR allowed during first-time setup (no users yet)
// @access Admin or setup
router.post('/logo', brandingAssetAccess, (req, res, next) => {
uploadLogo.single('image')(req, res, (err) => {
if (err) req.multerError = err;
next();
});
}, uploadLogoImage);
// @route POST /api/uploads/favicon
// @desc Upload site favicon — admin, OR allowed during first-time setup (no users yet)
// @access Admin or setup
router.post('/favicon', brandingAssetAccess, (req, res, next) => {
uploadFavicon.single('image')(req, res, (err) => {
if (err) req.multerError = err;
next();
});
}, uploadFaviconImage);
module.exports = router;
-67
View File
@@ -1,67 +0,0 @@
const express = require('express');
const router = express.Router();
const {
registerUser,
loginUser,
verifyLoginTwoFactor,
resendLoginTwoFactor,
startEnableTwoFactor,
confirmEnableTwoFactor,
disableTwoFactor,
verifyPassword,
getUserProfile,
updateUserProfile,
getUsers,
checkUserExists,
getUserById,
updateUser,
deleteUser,
anonymizeUser,
requestPasswordReset,
resetPassword,
activateAccount,
revokeMySession,
adminRevokeUserSessions,
closeAccount,
getMyActivity,
} = require('../controllers/userController');
const { protect, admin, supervisor, loginLimiter} = require('../middleware/authMiddleware');
const { campusIndependent } = require('../utils/campusScope');
// Public routes
router.post('/', registerUser);
router.post('/login', loginLimiter, loginUser);
router.post('/login/2fa', loginLimiter, verifyLoginTwoFactor);
router.post('/login/2fa/resend', loginLimiter, resendLoginTwoFactor);
router.post('/forgot', requestPasswordReset);
router.post('/reset', resetPassword);
router.post('/activate', activateAccount);
// Protected routes
router.route('/profile')
.get(protect, getUserProfile)
.put(protect, updateUserProfile);
router.post('/revoke-sessions', protect, revokeMySession);
router.post('/close-account', protect, closeAccount);
router.get('/activity', protect, getMyActivity);
router.post('/verify-password', protect, loginLimiter, verifyPassword);
router.post('/2fa/enable', protect, loginLimiter, startEnableTwoFactor);
router.post('/2fa/confirm', protect, loginLimiter, confirmEnableTwoFactor);
router.post('/2fa/disable', protect, loginLimiter, disableTwoFactor);
// Admin routes
router.route('/')
.get(protect, supervisor, campusIndependent('accounts are shared across campuses'), getUsers);
router.get('/check-exists', protect, supervisor, campusIndependent('accounts are shared across campuses'), checkUserExists);
router.route('/:id')
.get(protect, admin, getUserById)
.put(protect, admin, updateUser)
.delete(protect, admin, deleteUser);
router.post('/:id/revoke-sessions', protect, admin, adminRevokeUserSessions);
router.post('/:id/anonymize', protect, admin, anonymizeUser);
module.exports = router;
-33
View File
@@ -1,33 +0,0 @@
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;
-20
View File
@@ -1,20 +0,0 @@
const express = require('express');
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.' } });
router.post('/', optionalAuth, joinWaitlist);
router.get('/mine', protect, myWaitlist);
router.get('/claim/:token', claimLimiter, claimInfo);
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;
+3 -2
View File
@@ -1,11 +1,12 @@
const express = require('express');
const router = express.Router();
const { handleYocoWebhook, handleWhatsappWebhook } = require('../controllers/webhookController');
const { handleYocoWebhook } = require('../controllers/webhookController');
const { handleProviderWebhook } = require('../payments/webhook');
// Payment provider webhooks (src/payments)
router.post('/yoco', handleYocoWebhook);
router.post('/payfast', handleProviderWebhook('payfast'));
router.post('/whatsapp', handleWhatsappWebhook);
// The WAWP handler (WAWP is registered at /api/whatsapp/webhook, now /api/v2/webhooks/whatsapp).
router.post('/whatsapp', ...require('../v2/links').whatsappWebhook);
module.exports = router;
@@ -1,11 +0,0 @@
const express = require('express');
const router = express.Router();
const { previewWhatsAppBroadcast, sendWhatsAppBroadcast, scheduleWhatsAppBroadcast } = require('../controllers/whatsappBroadcastController');
const { protect, supervisor } = require('../middleware/authMiddleware');
const { scopeBroadcast } = require('../utils/campusScope');
router.post('/preview', protect, supervisor, scopeBroadcast({ freeformField: 'phones' }), previewWhatsAppBroadcast);
router.post('/send', protect, supervisor, scopeBroadcast({ freeformField: 'phones' }), sendWhatsAppBroadcast);
router.post('/schedule', protect, supervisor, scopeBroadcast({ freeformField: 'phones' }), scheduleWhatsAppBroadcast);
module.exports = router;
-37
View File
@@ -1,37 +0,0 @@
const express = require('express');
const router = express.Router();
const {
getConfigHandler,
saveConfigHandler,
createInstanceHandler,
deleteInstanceHandler,
getStatusHandler,
getQrHandler,
requestCodeHandler,
logoutHandler,
startHandler,
restartHandler,
handleWebhook,
} = require('../controllers/whatsappController');
const { protect, admin } = require('../middleware/authMiddleware');
// Public webhook (called by WAWP servers)
router.post('/webhook', handleWebhook);
// Admin-only: credentials config
router.get( '/config', protect, admin, getConfigHandler);
router.post('/config', protect, admin, saveConfigHandler);
// Admin-only: instance lifecycle
router.post('/create-instance', protect, admin, createInstanceHandler);
router.post('/delete-instance', protect, admin, deleteInstanceHandler);
// Admin-only: session management
router.get( '/status', protect, admin, getStatusHandler);
router.get( '/qr', protect, admin, getQrHandler);
router.post('/request-code', protect, admin, requestCodeHandler);
router.post('/logout', protect, admin, logoutHandler);
router.post('/start', protect, admin, startHandler);
router.post('/restart', protect, admin, restartHandler);
module.exports = router;
-12
View File
@@ -1,12 +0,0 @@
const express = require('express');
const router = express.Router();
const { getConfig, updateConfig, getWebhooks, createWebhook, removeWebhook } = require('../controllers/yocoController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/config', protect, admin, getConfig);
router.put('/config', protect, admin, updateConfig);
router.get('/webhooks', protect, admin, getWebhooks);
router.post('/webhooks', protect, admin, createWebhook);
router.delete('/webhooks/:id', protect, admin, removeWebhook);
module.exports = router;
+14 -2
View File
@@ -12,6 +12,7 @@ const auth = require('../config/auth') as {
generateToken(id: string, role: string, tokenVersion: number): string;
hashPassword(pw: string): Promise<string>;
comparePassword(pw: string, hash: string): Promise<boolean>;
needsRehash(hash: string): boolean;
};
const twoFactor = require('../utils/twoFactor') as {
createChallenge(user: any, purpose: string, method: string): Promise<{ challengeId: string; method: string; destination: string | null }>;
@@ -207,6 +208,10 @@ export type LoginResult =
| { status: 'signed_in'; session: Session }
| { status: 'code_required'; challenge: { challengeId: string; method: string; destination: string | null }; enrolling: boolean };
let standIn: Promise<string> | null = null;
/** A hash nobody's password matches, so a sign-in with an unknown email does the same work. */
const standInHash = () => (standIn ??= auth.hashPassword(uuidv4()));
const MAX_ATTEMPTS = 5;
const LOCK_TIME = 5 * 60 * 1000;
@@ -219,7 +224,12 @@ export async function logIn(input: { identifier: string; password: string; devic
// SA local format (0xx) → international (27xx)
const normalizedPhone = whatsapp().normalizeZAPhone(rawInput) || rawInput.replace(/\D/g, '');
const user = await prisma.user.findFirst({ where: { OR: [{ email: rawInput }, ...(normalizedPhone ? [{ phoneNumber: normalizedPhone }] : [])] } });
if (!user) return fail(401, 'User does not exist');
// Security (1.51.17): an unknown email answers like a wrong password — same message and
// status, and a password check against a stand-in hash so it takes as long.
if (!user) {
await auth.comparePassword(input.password, await standInHash());
return fail(401, 'Invalid email or password', undefined, { v1Status: 400 });
}
if (!user.isActive) {
// Resend the activation link on each attempt, in case the earlier one expired.
@@ -240,7 +250,9 @@ export async function logIn(input: { identifier: string; password: string; devic
fail(401, 'Invalid email or password', undefined, { v1Status: 400 });
}
const updated = await prisma.user.update({ where: { id: user.id }, data: { failedAttempts: 0, lockUntil: null }, select: LOGIN_USER_SELECT });
// 2.0: a password saved with bcrypt (or weaker Argon2 settings) is re-hashed now we have it.
const rehashed = auth.needsRehash(user.password) ? { password: await auth.hashPassword(input.password) } : {};
const updated = await prisma.user.update({ where: { id: user.id }, data: { failedAttempts: 0, lockUntil: null, ...rehashed }, select: LOGIN_USER_SELECT });
// Two-factor: the password alone isn't enough unless this browser was remembered earlier.
if (updated.twoFactorEnabled && !(await twoFactor.isTrustedDevice(updated.id, input.deviceToken))) {
+345
View File
@@ -0,0 +1,345 @@
/**
* Corrections (1.52.0): putting registrations right when the normal flow didn't finish — tickets
* held back by a required attendee form, tickets needed before it's paid, a balance to write off,
* and check-ins after the event. Admins only, from Corrections; "Fill in later" (leaveFormForLater)
* is for any staff at the door and in Manual registrations.
*
* Every change is in the audit log with who did it and the reason given.
*/
import { prisma as typedPrisma } from '../v2/legacy';
import { fail } from './errors';
/* eslint-disable @typescript-eslint/no-explicit-any -- the records are Prisma's with includes */
const prisma = typedPrisma as any;
const { generateTicketsForRegistration, formsOutstanding, formPlaces, formNeedsAnswers } = require('../utils/ticketUtils') as {
generateTicketsForRegistration(id: string): Promise<any[]>;
formsOutstanding(registration: string | object): Promise<number>;
formPlaces(registrationOptions: any[]): number;
formNeedsAnswers(form: any): boolean;
};
const { computeRegistrationTotalDue } = require('../utils/pricing') as { computeRegistrationTotalDue(registration: any, at: Date): number };
const { recalculateRegistrationDiscount } = require('../utils/coupons') as { recalculateRegistrationDiscount(id: string): Promise<number> };
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string): Promise<unknown> };
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
const sessions = require('../utils/sessions') as {
activeSessions(sessions: any[]): any[];
sessionLabel(session: any, sessions: any[]): string;
lineCovers(line: any, sessionId: string): boolean;
lineSessions(line: any): string[] | null;
redeemedForSession(usages: any[], sessionId: string | null): number;
};
export type Actor = { id: string; role: string };
type Audit = { actor: Actor; ip?: string };
/** The key a single-date event's check-ins are counted under. */
export const WHOLE_EVENT = 'event';
const round2 = (n: number) => Math.round(n * 100) / 100;
const realEmail = (email: string | null | undefined) => (email && !email.endsWith('@guest.local') ? email : null);
const REGISTRATION_INCLUDE = {
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
payments: { select: { amount: true, createdAt: true } },
formResponses: { select: { id: true } },
attendees: { select: { id: true, name: true } },
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } },
variant: { select: { name: true } },
tranches: true,
tickets: {
include: { usages: { select: { quantityRedeemed: true, sessionId: true } }, attendee: { select: { name: true } }, holder: { select: { name: true } } },
orderBy: { createdAt: 'asc' },
},
},
},
};
async function emailTicketsTo(registrationId: string, userId: string) {
const { emailTickets } = require('../controllers/ticketController') as { emailTickets(req: any, res: any): Promise<unknown> };
const res: any = { status: () => res, json: () => {} };
try {
await emailTickets({ user: { id: userId }, body: { registrationId } }, res);
} catch (e: any) {
console.error('[corrections] Failed to send tickets:', e?.message || e);
}
}
async function loadEvent(eventId: string) {
const event = await prisma.event.findUnique({
where: { id: eventId },
select: {
id: true, title: true, startDate: true, endDate: true, scheduleType: true, cashupStatus: true,
sessions: { select: { id: true, label: true, startsAt: true, endsAt: true, cancelledAt: true } },
form: { include: { fields: { select: { type: true } } } },
},
});
return event || fail(404, 'Event not found');
}
/** One registration as Corrections shows it: money, forms, tickets and their check-ins. */
function correctionRow(reg: any, event: any, formRequired: boolean) {
const now = new Date();
const totalDue = round2(computeRegistrationTotalDue(reg, now));
const paid = round2((reg.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0));
const active = sessions.activeSessions(event.sessions || []);
const multi = event.scheduleType === 'sessions';
let places = 0;
let ticketPlaces = 0;
const tickets: any[] = [];
for (const ro of reg.registrationOptions || []) {
places += ro.quantity || 0;
const session = ro.sessionId ? active.find((s: any) => s.id === ro.sessionId) : null;
const label = [ro.eventOption?.name, ro.variant?.name].filter(Boolean).join(' — ')
+ (session ? ` (${sessions.sessionLabel(session, event.sessions)})` : '');
for (const t of ro.tickets || []) {
ticketPlaces += t.quantity || 0;
const covered = multi ? (sessions.lineSessions(ro) ?? active.map((s: any) => s.id)).filter((id: string) => active.some((s: any) => s.id === id)) : [WHOLE_EVENT];
const checkedIn: Record<string, number> = {};
for (const key of covered) checkedIn[key] = sessions.redeemedForSession(t.usages, multi ? key : null);
tickets.push({
id: t.id,
qrCode: t.qrCode,
label,
isMainTicket: !!ro.eventOption?.isMainTicket,
name: t.attendee?.name || t.holder?.name || reg.user?.name || '',
quantity: t.quantity || 1,
sessionIds: covered,
checkedIn,
});
}
}
const ticketsDue = reg.status === 'paid' || !!reg.ticketsReleasedAt;
const formsNeeded = formRequired && !reg.formWaivedAt ? Math.max(0, formPlaces(reg.registrationOptions) - (reg.formResponses || []).length) : 0;
return {
id: reg.id,
status: reg.status,
createdAt: reg.createdAt,
user: { id: reg.user?.id, name: reg.user?.name || '', email: realEmail(reg.user?.email), phoneNumber: reg.user?.phoneNumber || null },
totalDue,
paid,
balance: round2(Math.max(0, totalDue - paid)),
places,
ticketPlaces,
missingTickets: ticketsDue && ticketPlaces < places,
formsNeeded,
formWaived: !!reg.formWaivedAt,
ticketsReleased: !!reg.ticketsReleasedAt,
complimentary: Number(reg.complimentaryDiscount || 0),
tickets,
};
}
function eventSummary(event: any) {
const active = sessions.activeSessions(event.sessions || []);
return {
id: event.id,
title: event.title,
startDate: event.startDate,
endDate: event.endDate,
closed: event.cashupStatus === 'closed',
formRequired: formNeedsAnswers(event.form),
sessions: event.scheduleType === 'sessions'
? active.map((s: any) => ({ id: s.id, label: sessions.sessionLabel(s, event.sessions), startsAt: s.startsAt }))
: [],
};
}
/** An event's registrations (not cancelled) with what may need putting right. */
export async function eventCorrections(eventId: string) {
const event = await loadEvent(eventId);
const formRequired = formNeedsAnswers(event.form);
const regs = await prisma.registration.findMany({
where: { eventId, status: { not: 'cancelled' } },
include: REGISTRATION_INCLUDE,
orderBy: { createdAt: 'asc' },
});
return { event: eventSummary(event), registrations: regs.map((r: any) => correctionRow(r, event, formRequired)) };
}
async function registrationRow(registrationId: string) {
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, include: REGISTRATION_INCLUDE });
if (!reg) return fail(404, 'Registration not found');
const event = await loadEvent(reg.eventId);
return correctionRow(reg, event, formNeedsAnswers(event.form));
}
async function activeRegistration(registrationId: string) {
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, include: { registrationOptions: { include: { eventOption: true } } } });
if (!reg) return fail(404, 'Registration not found');
if (reg.status === 'cancelled') return fail(400, 'This registration is cancelled.');
return reg;
}
async function waiveForm(reg: any, actorId: string) {
if (reg.formWaivedAt || (await formsOutstanding(reg)) === 0) return false;
await prisma.registration.update({ where: { id: reg.id }, data: { formWaivedAt: new Date(), formWaivedById: actorId, updatedAt: new Date() } });
return true;
}
/**
* "Fill in later" at the door / in Manual registrations: the tickets stop waiting for the
* attendee form, and the payer is sent a link to fill it in (`notify`).
*/
export async function leaveFormForLater(registrationId: string, { actor, ip }: Audit, { notify = true }: { notify?: boolean } = {}) {
const reg = await activeRegistration(registrationId);
const waived = await waiveForm(reg, actor.id);
const created = await generateTicketsForRegistration(reg.id);
if (created.length > 0) await emailTicketsTo(reg.id, reg.userId);
let sentTo: string[] = [];
if (waived) {
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'attendee_form_waived', targetType: 'Registration', targetId: reg.id, metadata: { eventId: reg.eventId, from: 'door' }, ip });
if (notify) {
const { sendFormReminderMessage } = require('../utils/notifications') as { sendFormReminderMessage(id: string): Promise<string[]> };
sentTo = await sendFormReminderMessage(reg.id).catch(() => []);
}
}
return { ticketsIssued: created.length, sentTo };
}
type IssueOptions = { skipForm?: boolean; payLater?: boolean; send?: boolean; reason?: string | null };
/**
* Admin: issue a registration's tickets now — leaving the attendee form for later (`skipForm`)
* and/or before it's paid (`payLater`, the balance is still owed).
*/
export async function issueTickets(registrationId: string, { actor, ip }: Audit, opts: IssueOptions) {
const reg = await activeRegistration(registrationId);
if (reg.status !== 'paid' && !reg.ticketsReleasedAt && !opts.payLater) {
return fail(400, 'This registration isn\'t paid yet. Choose "pay later" or "make it free" to issue its tickets.');
}
const outstanding = opts.skipForm ? 0 : await formsOutstanding(reg);
if (outstanding > 0) {
return fail(400, `${outstanding} attendee form${outstanding === 1 ? ' is' : 's are'} still to fill in. Fill ${outstanding === 1 ? 'it' : 'them'} in, or choose to leave the form for later.`);
}
const waived = opts.skipForm ? await waiveForm(reg, actor.id) : false;
const released = !!opts.payLater && reg.status !== 'paid' && !reg.ticketsReleasedAt;
if (released) {
await prisma.registration.update({ where: { id: reg.id }, data: { ticketsReleasedAt: new Date(), ticketsReleasedById: actor.id, updatedAt: new Date() } });
}
const created = await generateTicketsForRegistration(reg.id);
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'tickets_issued_override', targetType: 'Registration', targetId: reg.id,
metadata: { eventId: reg.eventId, formLeftForLater: waived, payLater: released, ticketsCreated: created.length, reason: opts.reason || null },
ip,
});
if (opts.send && created.length > 0) await emailTicketsTo(reg.id, reg.userId);
if (waived) logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'attendee_form_waived', targetType: 'Registration', targetId: reg.id, metadata: { eventId: reg.eventId, from: 'corrections', reason: opts.reason || null }, ip });
return { ticketsIssued: created.length, registration: await registrationRow(reg.id) };
}
/**
* Admin: write off what's still owed ("make it free") — it comes off as a complimentary
* discount, so income stays what was really paid — and issue the tickets.
*/
export async function makeComplimentary(registrationId: string, { actor, ip }: Audit, opts: Omit<IssueOptions, 'payLater'>) {
const reg = await activeRegistration(registrationId);
try {
await assertEventOpen(reg.eventId);
} catch (e: any) {
return fail(400, e?.message || 'This event is closed.');
}
const full = await prisma.registration.findUnique({
where: { id: reg.id },
include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true },
});
const due = computeRegistrationTotalDue(full, new Date());
const paid = (full.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
const balance = round2(due - paid);
if (!(balance > 0.004)) return fail(400, 'Nothing is owed on this registration.');
await prisma.registration.update({ where: { id: reg.id }, data: { complimentaryDiscount: round2(Number(full.complimentaryDiscount || 0) + balance), updatedAt: new Date() } });
await recalculateRegistrationDiscount(reg.id);
await prisma.registration.update({ where: { id: reg.id }, data: { status: 'paid', updatedAt: new Date() } });
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'registration_complimentary', targetType: 'Registration', targetId: reg.id,
metadata: { eventId: reg.eventId, amount: balance, reason: opts.reason || null }, ip,
});
const waived = opts.skipForm ? await waiveForm(reg, actor.id) : false;
if (waived) logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'attendee_form_waived', targetType: 'Registration', targetId: reg.id, metadata: { eventId: reg.eventId, from: 'corrections', reason: opts.reason || null }, ip });
const created = await generateTicketsForRegistration(reg.id);
if (opts.send && created.length > 0) await emailTicketsTo(reg.id, reg.userId);
return { amount: balance, ticketsIssued: created.length, registration: await registrationRow(reg.id) };
}
/**
* Admin: every paid registration (or one released unpaid) at an event that's missing tickets
* gets them — forms still outstanding are left for later.
*/
export async function issueMissingTickets(eventId: string, { actor, ip }: Audit, { send = false }: { send?: boolean } = {}) {
const { registrations } = await eventCorrections(eventId);
const missing = registrations.filter((r: any) => r.missingTickets);
let tickets = 0;
let fixed = 0;
const failed: { id: string; name: string; message: string }[] = [];
for (const row of missing) {
try {
const reg = await activeRegistration(row.id);
await waiveForm(reg, actor.id);
const created = await generateTicketsForRegistration(reg.id);
tickets += created.length;
if (created.length > 0) fixed += 1;
if (send && created.length > 0) await emailTicketsTo(reg.id, reg.userId);
} catch (e: any) {
failed.push({ id: row.id, name: row.user.name, message: e?.message || 'Failed' });
}
}
if (missing.length > 0) {
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'tickets_issued_override', targetType: 'Event', targetId: eventId,
metadata: { registrations: fixed, ticketsCreated: tickets, formLeftForLater: true, sent: send, failed: failed.length }, ip,
});
}
return { registrations: fixed, tickets, failed };
}
type CheckInOptions = { sessionId?: string | null; people?: number | null; present?: boolean; reason?: string | null };
/**
* Admin: check a ticket in (or undo it) whatever the time — after the event or session has
* ended, outside the check-in window, or once the event is closed. Recorded as a manual
* check-in; no check-in message is sent.
*/
export async function correctCheckIn(ticketId: string, { actor, ip }: Audit, opts: CheckInOptions) {
const ticket = await prisma.ticket.findUnique({
where: { id: ticketId },
include: { usages: true, event: { select: { id: true, scheduleType: true } }, registrationOption: { select: { sessionId: true, sessionIds: true, registration: { select: { status: true } } } } },
});
if (!ticket) return fail(404, 'Ticket not found');
if (ticket.registrationOption?.registration?.status === 'cancelled') return fail(400, 'This registration is cancelled.');
const multi = ticket.event.scheduleType === 'sessions';
let sessionId: string | null = null;
if (multi) {
const session = opts.sessionId ? await prisma.eventSession.findUnique({ where: { id: opts.sessionId } }) : null;
if (!session || session.eventId !== ticket.eventId) return fail(400, 'Choose one of this event\'s sessions.');
if (session.cancelledAt) return fail(400, 'That session was cancelled.');
if (ticket.registrationOption && !sessions.lineCovers(ticket.registrationOption, session.id)) return fail(400, 'That ticket isn\'t booked for this session.');
sessionId = session.id;
}
const forSession = ticket.usages.filter((u: any) => (u.sessionId || null) === sessionId);
const redeemed = sessions.redeemedForSession(ticket.usages, sessionId);
const quantity = ticket.quantity || 1;
let changed = 0;
if (opts.present === false) {
if (forSession.length === 0) return fail(400, 'This ticket isn\'t checked in.');
await prisma.ticketUsage.deleteMany({ where: { id: { in: forSession.map((u: any) => u.id) } } });
if (!multi) await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: false, updatedAt: new Date() } });
changed = redeemed;
} else {
const left = quantity - redeemed;
if (left <= 0) return fail(400, 'Everyone on this ticket is already checked in.');
const people = Math.min(Math.max(1, Math.trunc(Number(opts.people)) || left), left);
await prisma.ticketUsage.create({ data: { ticketId: ticket.id, scannedById: actor.id, quantityRedeemed: people, sessionId, manual: true } });
if (!multi && redeemed + people >= quantity) await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: true, updatedAt: new Date() } });
changed = people;
}
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'checkin_corrected', targetType: 'Ticket', targetId: ticket.id,
metadata: { eventId: ticket.eventId, sessionId, present: opts.present !== false, people: changed, reason: opts.reason || null }, ip,
});
const after = await prisma.ticketUsage.findMany({ where: { ticketId: ticket.id }, select: { quantityRedeemed: true, sessionId: true } });
return { checkedIn: sessions.redeemedForSession(after, sessionId), quantity };
}
+18 -20
View File
@@ -18,7 +18,10 @@ import {
/* eslint-disable @typescript-eslint/no-explicit-any -- moved from JavaScript; the records are Prisma's with includes */
// The queries below build their includes and data step by step, as the JavaScript did.
const prisma = typedPrisma as any;
const { generateTicketsForRegistration } = require('../utils/ticketUtils') as { generateTicketsForRegistration(id: string): Promise<any[]> };
const { generateTicketsForRegistration, formsOutstanding } = require('../utils/ticketUtils') as {
generateTicketsForRegistration(id: string): Promise<any[]>;
formsOutstanding(registration: string | object): Promise<number>;
};
const { emailTickets } = require('../controllers/ticketController') as { emailTickets(req: any, res: any): Promise<unknown> };
const { hashPassword } = require('../config/auth') as { hashPassword(p: string): Promise<string> };
const { computeRegistrationTotalDue, refreshPricingForRegistration, attachComputedTotals, attachComputedTotalsToList } = require('../utils/pricing') as {
@@ -111,13 +114,9 @@ async function syncStatusAfterDiscount(registrationId: string) {
if (status === 'paid' && reg.status !== 'paid') {
(async () => {
try {
let hasRequiredForm = false;
try {
const form = await prisma.eventForm.findUnique({ where: { eventId: reg.eventId } });
hasRequiredForm = !!(form && form.isRequired);
} catch {}
if (hasRequiredForm) return;
await generateTicketsForRegistration(registrationId);
// Waits while a required attendee form is outstanding (see utils/ticketUtils).
const created = await generateTicketsForRegistration(registrationId);
if (created.length === 0) return;
const mockReq = { user: { id: reg.userId }, body: { registrationId } };
const mockRes = { status: () => mockRes, json: () => {} };
await emailTickets(mockReq, mockRes);
@@ -840,13 +839,6 @@ export const createManualRegistration = as400(async (call: Call): Promise<any> =
});
}
// Auto mark paid and generate tickets if free and no required form (new registrations only)
let hasRequiredForm = false;
try {
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } });
hasRequiredForm = !!(form && form.isRequired);
} catch {}
// Auto-mark paid and generate tickets for free registrations (new OR existing that became free)
if (registration.status !== 'paid') try {
const totalDue = computeRegistrationTotalDue(registration, new Date());
@@ -856,7 +848,12 @@ export const createManualRegistration = as400(async (call: Call): Promise<any> =
}
} catch {}
// Generate/sync tickets for all paid registrations that have no required form
// Attendee forms still to fill in (a required form holds the tickets back until they're in,
// or until staff choose "Fill in later" — POST /registrations/:id/form-later).
const formsNeeded = await formsOutstanding(registration.id);
const hasRequiredForm = formsNeeded > 0;
// Generate/sync tickets for all paid registrations that aren't waiting for a form
if (registration.status === 'paid' && !hasRequiredForm) try {
await generateTicketsForRegistration(registration.id);
} catch {}
@@ -912,7 +909,7 @@ export const createManualRegistration = as400(async (call: Call): Promise<any> =
ip: call.ip,
});
return { ...attachComputedTotals(registration), ...(couponError ? { couponError } : {}) };
return { ...attachComputedTotals(registration), formsNeeded, ...(couponError ? { couponError } : {}) };
});
// @desc Update registration options (add/remove items) with constraints
@@ -1359,11 +1356,12 @@ export const submitFormResponses = as400(async (call: Call): Promise<any> => {
created.push(fr);
}
// After submitting responses, if registration is already paid, generate and email tickets now.
// After submitting responses, if registration is already paid (or its tickets were released
// unpaid), generate and email tickets now.
let generatedTickets = [];
try {
const regAfter = await prisma.registration.findUnique({ where: { id: registrationId } });
if (regAfter && regAfter.status === 'paid') {
if (regAfter && (regAfter.status === 'paid' || regAfter.ticketsReleasedAt)) {
generatedTickets = await generateTicketsForRegistration(registrationId);
if (generatedTickets && generatedTickets.length > 0) {
try {
@@ -1449,7 +1447,7 @@ export const replaceFormResponses = as400(async (call: Call): Promise<any> => {
// Attempt ticket generation and email if already paid
try {
const regAfter = await prisma.registration.findUnique({ where: { id: registrationId }, include: { user: { select: { id: true } } } });
if (regAfter && regAfter.status === 'paid') {
if (regAfter && (regAfter.status === 'paid' || regAfter.ticketsReleasedAt)) {
const newTickets = await generateTicketsForRegistration(registrationId);
if (newTickets && newTickets.length > 0) {
try {
+5 -3
View File
@@ -8,7 +8,10 @@ import { prisma } from '../v2/legacy';
import { fail, ServiceError } from './errors';
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
const { generateTicketsForRegistration } = require('../utils/ticketUtils') as { generateTicketsForRegistration(id: string): Promise<unknown> };
const { generateTicketsForRegistration, formsOutstanding } = require('../utils/ticketUtils') as {
generateTicketsForRegistration(id: string): Promise<unknown>;
formsOutstanding(registration: string | object): Promise<number>;
};
const pricing = require('../utils/pricing') as {
resolveOptionPrice(option: any, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
resolveVariantTierPrice(option: any, variantId: string, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
@@ -410,8 +413,7 @@ async function create(input: CreateRegistrationInput, actor: Actor) {
if (registration.status !== 'paid') {
await prisma.registration.update({ where: { id: registrationId }, data: { status: 'paid', updatedAt: new Date() } });
}
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } }).catch(() => null);
if (!(form && form.isRequired)) {
if ((await formsOutstanding(registrationId)) === 0) {
// Same generator as paid registrations: a ticket per named attendee, and one
// shared quantity ticket for the rest of each line.
await generateTicketsForRegistration(registrationId);
+1
View File
@@ -18,6 +18,7 @@ const ACCESS_ACTIONS = {
kids_rooms: 'Kids check-in desk or room opened',
kids_offline: 'Kids check-in list downloaded for offline use',
volunteer_roster: 'Volunteer roster opened',
ai_feedback: 'Feedback comments summarised by the AI helper',
};
/**
+3 -2
View File
@@ -256,7 +256,8 @@ async function registrationBundles(registration, db = prisma) {
* left. Percentage and comp codes are recalculated over the new items, fixed codes keep
* their amount (capped at what's still in scope). `keepCouponAmounts` keeps every
* coupon's amount (capped the same way): used when only prices changed, e.g. an
* early-bird tier expiring. Updates bundleDiscount and discountTotal and returns
* early-bird tier expiring. A balance an admin wrote off (`complimentaryDiscount`, 1.52.0)
* comes off last, as a fixed amount. Updates bundleDiscount and discountTotal and returns
* discountTotal. Pass `tx` to run inside the caller's transaction.
*/
async function recalculateRegistrationDiscount(registrationId, db = prisma, { keepCouponAmounts = false } = {}) {
@@ -280,7 +281,7 @@ async function recalculateRegistrationDiscount(registrationId, db = prisma, { ke
}
total += amount;
}
total = round2(total);
total = round2(total + Number(registration.complimentaryDiscount || 0));
if (Math.abs(total - registration.discountTotal) > 0.001 || Math.abs(bundles.discount - registration.bundleDiscount) > 0.001) {
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: total, bundleDiscount: bundles.discount } });
}
+2 -1
View File
@@ -17,7 +17,8 @@ const prisma = require('../config/db');
const MAX_MINUTES = 30;
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
const ALLOWED_WRITES = ['/api/admin/impersonate/end', '/api/admin/impersonate/log'];
// v1 and v2 paths for ending a viewing-as session and logging what was viewed.
const ALLOWED_WRITES = ['/api/v2/me/impersonation/end', '/api/v2/me/impersonation/log'];
/** The `imp` claim of a bearer token, or null (no token, invalid token, or not impersonating). */
function impersonationClaim(req) {
+40 -1
View File
@@ -34,7 +34,7 @@ function getBackendUrl() {
function calendarLinkRow(eventId) {
const backendUrl = getBackendUrl();
if (!backendUrl || !eventId) return '';
return secondaryLink('Add to calendar', `${backendUrl}/api/events/${eventId}/ics`);
return secondaryLink('Add to calendar', `${backendUrl}/api/v2/events/${eventId}/calendar.ics`);
}
function getOrg() {
@@ -1512,10 +1512,49 @@ async function sendAttendeeFormLinkMessage(attendee) {
return sent;
}
// "Fill in later" (1.52.0): the attendee form was left for later at the door, so the payer is
// sent a link to the forms still to do — by email and/or WhatsApp as they prefer.
async function sendFormReminderMessage(registrationId) {
const org = getOrg();
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, include: { event: { select: { title: true } }, user: true } });
if (!reg || !reg.user) return [];
const { shouldEmail, waText, canWhatsApp } = require('./notify');
const eventTitle = reg.event?.title || 'the event';
const link = `${org.url}/forms?registrationId=${encodeURIComponent(reg.id)}`;
const sent = [];
if (reg.user.email && !reg.user.email.endsWith('@guest.local') && shouldEmail(reg.user)) {
const html = emailWrapper(`
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">Please fill in your attendee form</p>
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${reg.user.name}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">Thanks for registering for <strong>${eventTitle}</strong>. We still need the attendee form — it only takes a minute.</p>
${ctaButton('Fill in the form', link)}
${fallbackLink(link)}
`, { preheader: `Your attendee form for ${eventTitle}` });
try {
await sendMail({ to: reg.user.email, subject: `Your attendee form for ${eventTitle}`, html, text: `Hi ${reg.user.name},
Thanks for registering for ${eventTitle}. We still need the attendee form:
${link}
${org.name}` });
sent.push('email');
} catch (e) {
console.warn('[form reminder] email failed:', e?.message || e);
}
}
if (canWhatsApp(reg.user)) {
await waText(reg.user, `Hi ${reg.user.name}, thanks for registering for *${eventTitle}*. We still need your attendee form: ${link}`);
sent.push('WhatsApp');
}
return sent;
}
module.exports = {
getEventNotifyEmails,
buildEventNotifyRecipientList,
sendAttendeeFormLinkMessage,
sendFormReminderMessage,
sendRegistrationEmails,
sendRegistrationUpdatedEmails,
sendPaymentEmails,
+1 -1
View File
@@ -38,7 +38,7 @@ const payfastHost = (mode) => HOSTS[mode === 'live' ? 'live' : 'sandbox'];
/** Where PayFast should post ITNs: BACKEND_URL (or the request's origin) + /api/webhooks/payfast. */
function payfastNotifyUrl(req) {
const base = (process.env.BACKEND_URL || (req ? `${req.protocol}://${req.get('host')}` : '')).replace(/\/$/, '');
return base ? `${base}/api/webhooks/payfast` : '';
return base ? `${base}/api/v2/webhooks/payfast` : '';
}
/** Why PayFast can't reach the notify URL (null when it can). */
+10 -4
View File
@@ -96,10 +96,10 @@ function buildLineItems(registrationOptions) {
/**
* The registration's coupon discount and a label naming the code(s), e.g. "Code STAFF50".
* Needs `couponRedemptions[].coupon.code` loaded for the label; the amount is
* Registration.discountTotal less the bundle part, both always present.
* Registration.discountTotal less the bundle and complimentary parts, all always present.
*/
function couponDiscountLine(registration) {
const amount = Math.round((Number(registration?.discountTotal || 0) - Number(registration?.bundleDiscount || 0)) * 100) / 100;
const amount = Math.round((Number(registration?.discountTotal || 0) - Number(registration?.bundleDiscount || 0) - Number(registration?.complimentaryDiscount || 0)) * 100) / 100;
if (!(amount > 0.001)) return null;
const codes = (registration.couponRedemptions || []).map(r => r.coupon?.code).filter(Boolean);
return { amount, label: codes.length ? `Code ${codes.join(', ')}` : 'Discount code' };
@@ -118,9 +118,15 @@ function bundleDiscountLine(registration) {
return { amount, label: names.length ? `Bundle: ${names.join(', ')}` : 'Bundle deal' };
}
/** Bundle saving then coupon discount, as display lines ({ label, amount }). */
/** A balance written off by an admin (Corrections → Make it free, 1.52.0). */
function complimentaryLine(registration) {
const amount = Number(registration?.complimentaryDiscount || 0);
return amount > 0.001 ? { amount, label: 'Complimentary' } : null;
}
/** Bundle saving, coupon discount, then anything made complimentary, as display lines ({ label, amount }). */
function discountLines(registration) {
return [bundleDiscountLine(registration), couponDiscountLine(registration)].filter(Boolean);
return [bundleDiscountLine(registration), couponDiscountLine(registration), complimentaryLine(registration)].filter(Boolean);
}
/** Draws a two-column item table starting at `y`; returns the y position after the last row. */
+3 -1
View File
@@ -120,7 +120,7 @@ function unsubscribePageUrl(target) {
/** RFC 8058 one-click endpoint, for the List-Unsubscribe header (mail apps POST to it). */
function oneClickUrl(target) {
return `${backendUrl()}/api/privacy/unsubscribe/${encodeURIComponent(unsubscribeToken(target))}/one-click`;
return `${backendUrl()}/api/v2/unsubscribe/${encodeURIComponent(unsubscribeToken(target))}/one-click`;
}
/** What an unsubscribe link refers to, for the landing page. */
@@ -469,6 +469,8 @@ async function anonymiseUser(userId, db = prisma) {
await tx.webhookDelivery.deleteMany({ where: { userId } });
// Their saved list filters (can hold search text, such as names).
await tx.savedFilter.deleteMany({ where: { userId } });
// AI helper calls (2.0): kept for the site's monthly totals, no longer linked to them.
await tx.aiCall.updateMany({ where: { userId }, data: { userId: null } });
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length, familyDeleted, shiftsGivenUp };
});
+10
View File
@@ -96,6 +96,7 @@ const DEFAULT_SECTIONS = [
'• Tell you when a new event in a series you follow comes out. This counts as news, so it\'s only sent if you get news and announcements.',
'• Offer places from a waitlist, arrange rooms and groups, and ask for parental consent on youth events.',
'• Send you an optional feedback form after an event, with one reminder.',
'• If we use the AI helpers: our staff can ask an AI service to help draft event details and messages, improve text, and summarise reports and event feedback. A person checks every suggestion before anything is saved or sent. Only what a task needs is sent — never your name, contact details, or medical or children\'s information — and feedback comments are sent without who wrote them (see section 9).',
'• Manage user accounts and event attendance records, and give support when you ask for help.',
'• Improve our events and website (using anonymous statistics, and only with your permission).',
'• Comply with legal obligations.',
@@ -166,6 +167,7 @@ const DEFAULT_SECTIONS = [
'We do not sell or rent your personal information. Outside our organisation, it is shared only with these service providers ("operators"), each only for its purpose:',
'{operators}',
'• Anyone we are required to share it with under South African law.',
'If we use the AI helpers, requests go through our software provider\'s service to Anthropic (the Claude AI service) in the United States, which is listed above. Our software provider does not keep what was sent. Anthropic handles it under its commercial terms, which do not allow it to be used to train AI; it may be kept for a short time for safety checks. Because this is outside South Africa, it is only done under the protections POPIA requires for such transfers.',
'If we connect another system — for example our church-management software — through a webhook, it is listed above with what it receives. It gets only the updates we chose (such as new registrations, payments and check-ins), and never children\'s details from kids check-in.',
'Within our organisation, people see only what they need:',
"• Door staff see a ticket's holder name, ticket type and check-in status when scanning.",
@@ -279,6 +281,14 @@ const BUILT_IN_UPDATES = [
],
sections: ['summary', 'information', 'use', 'whatsapp', 'retention', 'sharing', 'cookies', 'changes'],
},
{
revision: '2.0.0',
summary: [
'AI helpers: staff can ask an AI service (Anthropic\'s Claude, through the software provider) to draft events and messages, improve text and summarise reports and feedback; a person checks every suggestion, and no names, contact details, medical or children\'s information are sent.',
'Where AI requests go (the United States), that the provider doesn\'t keep them, and that they aren\'t used to train AI.',
],
sections: ['use', 'sharing'],
},
];
const BUILT_IN_REVISION = BUILT_IN_UPDATES[BUILT_IN_UPDATES.length - 1].revision;
const REVIEWED_KEY = 'privacy_notice_builtin_reviewed';
+53 -14
View File
@@ -2,8 +2,55 @@ const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { planLineTickets } = require('./attendees');
/** Form fields that are only text to read (a statement, a heading). */
const DISPLAY_FIELDS = ['statement', 'paragraph'];
/** A required form with at least one question to answer — the only kind tickets wait for. */
const formNeedsAnswers = (form) => !!(form && form.isRequired && (form.fields || []).some(f => !DISPLAY_FIELDS.includes(f.type)));
/**
* Generate tickets for a registration when payment status is "paid".
* How many attendee forms a registration's places need: one per main-ticket place. A picked-
* session booking has a line per session for the same people, so it counts once (its biggest
* line), not once per session.
*/
function formPlaces(registrationOptions) {
let total = 0;
const picked = new Map();
for (const ro of registrationOptions || []) {
if (!ro.eventOption?.isMainTicket) continue;
const qty = ro.quantity || 0;
if (!ro.sessionId) { total += qty; continue; }
const key = `${ro.eventOptionId}::${ro.variantId || ''}`;
picked.set(key, Math.max(picked.get(key) || 0, qty));
}
for (const qty of picked.values()) total += qty;
return total;
}
/**
* The attendee forms a registration still needs before its tickets can be issued: 0 when the
* event's form isn't required (or has no questions), when the form was left for later
* (`formWaivedAt`), or when enough are in. `registration` is an id or a record with
* registrationOptions[].eventOption.
*/
async function formsOutstanding(registration) {
try {
const reg = typeof registration === 'string'
? await prisma.registration.findUnique({ where: { id: registration }, include: { registrationOptions: { include: { eventOption: true } } } })
: registration;
if (!reg || reg.formWaivedAt) return 0;
const form = await prisma.eventForm.findUnique({ where: { eventId: reg.eventId }, include: { fields: true } });
if (!formNeedsAnswers(form)) return 0;
const submitted = await prisma.formResponse.count({ where: { registrationId: reg.id } });
return Math.max(0, formPlaces(reg.registrationOptions) - submitted);
} catch (_) {
return 0; // form models unavailable — don't block
}
}
/**
* Generate tickets for a registration when payment status is "paid" (or an admin released
* them unpaid) and no required attendee form is outstanding (see formsOutstanding).
*
* Rules:
* - Each named attendee (group tickets) gets their own quantity-1 ticket; the rest of a
@@ -34,19 +81,11 @@ const generateTicketsForRegistration = async (registrationId) => {
});
if (!registration) throw new Error('Registration not found');
if (registration.status !== 'paid') return [];
// Paid, or an admin issued the tickets before payment ("pay later", 1.52.0).
if (registration.status !== 'paid' && !(registration.ticketsReleasedAt && registration.status !== 'cancelled')) return [];
// If event has a required form, ensure sufficient responses exist
try {
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } });
if (form && form.isRequired) {
const requiredCount = (registration.registrationOptions || [])
.filter(ro => ro.eventOption?.isMainTicket)
.reduce((s, ro) => s + (ro.quantity || 0), 0);
const responsesCount = await prisma.formResponse.count({ where: { registrationId } });
if (responsesCount < requiredCount) return [];
}
} catch (_) { /* form models unavailable — don't block */ }
// A required attendee form comes first, unless it's been left for later.
if ((await formsOutstanding(registration)) > 0) return [];
// ── Step 1: Consolidate duplicate registrationOptions for the same (eventOptionId, variantId, session) ──
// Key on both fields so that different variants of the same option are never merged
@@ -133,4 +172,4 @@ const generateTicketsForRegistration = async (registrationId) => {
}
};
module.exports = { generateTicketsForRegistration };
module.exports = { generateTicketsForRegistration, formsOutstanding, formPlaces, formNeedsAnswers, DISPLAY_FIELDS };
+1 -1
View File
@@ -314,7 +314,7 @@ async function getWebhookSecret() {
/** The URL to paste into WAWP's webhook settings (events: message, session.status). */
async function webhookUrl() {
const base = String(process.env.BACKEND_URL || process.env.API_URL || 'http://localhost:5000').replace(/\/$/, '');
return `${base}/api/whatsapp/webhook?secret=${encodeURIComponent(await getWebhookSecret())}`;
return `${base}/api/v2/webhooks/whatsapp?secret=${encodeURIComponent(await getWebhookSecret())}`;
}
/** The sender's phone (27…) from a WAWP chat id, or null for groups, channels and LIDs. */
+1 -1
View File
@@ -114,7 +114,7 @@ const deleteWebhook = (id) => yocoRequest('delete', `/webhooks/${encodeURIComp
*/
function defaultWebhookUrl(req) {
const base = (process.env.BACKEND_URL || (req ? `${req.protocol}://${req.get('host')}` : '')).replace(/\/$/, '');
return base ? `${base}/api/webhooks/yoco` : '';
return base ? `${base}/api/v2/webhooks/yoco` : '';
}
/** Returns an error message if Yoco won't be able to reach the URL, otherwise null. */
-137
View File
@@ -1,137 +0,0 @@
import type { RequestHandler } from 'express';
/**
* v1 endpoints that have a v2 replacement say so (1.51.3): a `Deprecation` header (RFC 9745,
* the date v2 covered them) and `Link: <…>; rel="successor-version"` pointing at the v2
* endpoint. v1 keeps working until 2.0; endpoints without a v2 version get no header yet.
*/
// When these v1 endpoints got their v2 replacement (1.51.3's release day).
const DEPRECATED_AT = Math.floor(Date.UTC(2026, 9, 29) / 1000);
type Successor = { method: string; v1: RegExp; v2: (m: RegExpMatchArray) => string };
const SUCCESSORS: Successor[] = [
{ method: 'GET', v1: /^\/api\/events\/?$/, v2: () => '/api/v2/events' },
// v1's event detail is the full record (v2's /events/{id} is the public summary).
{ method: 'GET', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/full` },
// 1.51.8: the rest of the events area
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/ics$/, v2: m => `/api/v2/events/${m[1]}/calendar.ics` },
{ method: 'GET', v1: /^\/api\/events\/by-alias\/([^/]+)$/, v2: m => `/api/v2/events/by-alias/${m[1]}` },
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/options$/, v2: m => `/api/v2/events/${m[1]}/ticket-options` },
{ method: 'PUT', v1: /^\/api\/events\/options\/([^/]+)$/, v2: m => `/api/v2/ticket-options/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/events\/options\/([^/]+)$/, v2: m => `/api/v2/ticket-options/${m[1]}` },
{ method: 'POST', v1: /^\/api\/events\/options\/([^/]+)\/variants$/, v2: m => `/api/v2/ticket-options/${m[1]}/variants` },
{ method: 'PUT', v1: /^\/api\/events\/variants\/([^/]+)$/, v2: m => `/api/v2/variants/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/events\/variants\/([^/]+)$/, v2: m => `/api/v2/variants/${m[1]}` },
{ method: 'GET', v1: /^\/api\/events\/attachments\/status$/, v2: () => '/api/v2/attachments/status' },
{ method: 'POST', v1: /^\/api\/events\/attachments\/sync$/, v2: () => '/api/v2/attachments/sync' },
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/attachments$/, v2: m => `/api/v2/events/${m[1]}/attachments` },
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/attachments$/, v2: m => `/api/v2/events/${m[1]}/attachments` },
{ method: 'DELETE', v1: /^\/api\/events\/([^/]+)\/attachments\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/attachments/${m[2]}` },
{ method: 'GET', v1: /^\/api\/sections\/?$/, v2: () => '/api/v2/sections' },
{ method: 'POST', v1: /^\/api\/sections\/?$/, v2: () => '/api/v2/sections' },
{ method: 'PUT', v1: /^\/api\/sections\/([^/]+)$/, v2: m => `/api/v2/sections/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/sections\/([^/]+)$/, v2: m => `/api/v2/sections/${m[1]}` },
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/costs$/, v2: m => `/api/v2/events/${m[1]}/costs` },
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/costs$/, v2: m => `/api/v2/events/${m[1]}/costs` },
{ method: 'PUT', v1: /^\/api\/costs\/([^/]+)$/, v2: m => `/api/v2/costs/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/costs\/([^/]+)$/, v2: m => `/api/v2/costs/${m[1]}` },
{ method: 'POST', v1: /^\/api\/uploads\/(event-image|logo|favicon)$/, v2: m => `/api/v2/uploads/${m[1]}` },
// 1.51.7: setting events up
{ method: 'GET', v1: /^\/api\/events\/all$/, v2: () => '/api/v2/events/manage' },
{ method: 'POST', v1: /^\/api\/events\/?$/, v2: () => '/api/v2/events' },
{ method: 'PUT', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}` },
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/copy-from$/, v2: m => `/api/v2/events/${m[1]}/copy-from` },
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
{ method: 'PUT', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
// 1.51.10: managing registrations
{ method: 'GET', v1: /^\/api\/registrations\/?$/, v2: () => '/api/v2/registrations' },
{ method: 'GET', v1: /^\/api\/registrations\/myregistrations$/, v2: () => '/api/v2/me/registrations/full' },
{ method: 'GET', v1: /^\/api\/registrations\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/registrations` },
{ method: 'POST', v1: /^\/api\/registrations\/manual$/, v2: () => '/api/v2/registrations/manual' },
{ method: 'POST', v1: /^\/api\/registrations\/bulk$/, v2: () => '/api/v2/registrations/bulk' },
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/options$/, v2: m => `/api/v2/registrations/${m[1]}/items` },
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/send-invoice$/, v2: m => `/api/v2/registrations/${m[1]}/invoice` },
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/coupon$/, v2: m => `/api/v2/registrations/${m[1]}/coupon` },
{ method: 'DELETE', v1: /^\/api\/registrations\/([^/]+)\/coupon$/, v2: m => `/api/v2/registrations/${m[1]}/coupon` },
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/attendees$/, v2: m => `/api/v2/registrations/${m[1]}/attendees` },
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/attendees\/([^/]+)\/send-form-link$/, v2: m => `/api/v2/registrations/${m[1]}/attendees/${m[2]}/form-link` },
{ method: 'GET', v1: /^\/api\/registrations\/([^/]+)\/forms\/draft$/, v2: m => `/api/v2/registrations/${m[1]}/form-draft` },
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/forms\/draft$/, v2: m => `/api/v2/registrations/${m[1]}/form-draft` },
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/forms\/responses$/, v2: m => `/api/v2/registrations/${m[1]}/form-responses` },
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/forms\/responses$/, v2: m => `/api/v2/registrations/${m[1]}/form-responses` },
{ method: 'GET', v1: /^\/api\/registrations\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/full` },
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/status` },
// 1.51.9: series, season passes, bundles, coupons, campuses
{ method: 'GET', v1: /^\/api\/series\/?$/, v2: () => '/api/v2/series' },
{ method: 'POST', v1: /^\/api\/series\/?$/, v2: () => '/api/v2/series' },
{ method: 'GET', v1: /^\/api\/series\/by-slug\/([^/]+)$/, v2: m => `/api/v2/series/by-slug/${m[1]}` },
{ method: 'GET', v1: /^\/api\/series\/([^/]+)\/compare$/, v2: m => `/api/v2/series/${m[1]}/compare` },
{ method: 'POST', v1: /^\/api\/series\/([^/]+)\/follow$/, v2: m => `/api/v2/series/${m[1]}/follow` },
{ method: 'DELETE', v1: /^\/api\/series\/([^/]+)\/follow$/, v2: m => `/api/v2/series/${m[1]}/follow` },
{ method: 'PUT', v1: /^\/api\/series\/([^/]+)$/, v2: m => `/api/v2/series/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/series\/([^/]+)$/, v2: m => `/api/v2/series/${m[1]}` },
{ method: 'GET', v1: /^\/api\/season-passes\/mine$/, v2: () => '/api/v2/me/season-passes' },
{ method: 'POST', v1: /^\/api\/season-passes\/purchases\/([^/]+)\/checkout$/, v2: m => `/api/v2/season-pass-purchases/${m[1]}/checkout` },
{ method: 'POST', v1: /^\/api\/season-passes\/([^/]+)\/buy$/, v2: m => `/api/v2/season-passes/${m[1]}/buy` },
{ method: 'GET', v1: /^\/api\/season-passes\/?$/, v2: () => '/api/v2/season-passes' },
{ method: 'POST', v1: /^\/api\/season-passes\/?$/, v2: () => '/api/v2/season-passes' },
{ method: 'PUT', v1: /^\/api\/season-passes\/([^/]+)$/, v2: m => `/api/v2/season-passes/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/season-passes\/([^/]+)$/, v2: m => `/api/v2/season-passes/${m[1]}` },
{ method: 'POST', v1: /^\/api\/bundles\/quote$/, v2: () => '/api/v2/bundles/quote' },
{ method: 'GET', v1: /^\/api\/bundles\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/bundles` },
{ method: 'POST', v1: /^\/api\/bundles\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/bundles` },
{ method: 'PUT', v1: /^\/api\/bundles\/([^/]+)$/, v2: m => `/api/v2/bundles/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/bundles\/([^/]+)$/, v2: m => `/api/v2/bundles/${m[1]}` },
{ method: 'POST', v1: /^\/api\/coupons\/validate$/, v2: () => '/api/v2/coupons/check' },
{ method: 'GET', v1: /^\/api\/coupons\/redemptions$/, v2: () => '/api/v2/coupons/redemptions' },
{ method: 'GET', v1: /^\/api\/coupons\/?$/, v2: () => '/api/v2/coupons' },
{ method: 'POST', v1: /^\/api\/coupons\/?$/, v2: () => '/api/v2/coupons' },
{ method: 'GET', v1: /^\/api\/coupons\/([^/]+)$/, v2: m => `/api/v2/coupons/${m[1]}` },
{ method: 'PUT', v1: /^\/api\/coupons\/([^/]+)$/, v2: m => `/api/v2/coupons/${m[1]}` },
{ method: 'GET', v1: /^\/api\/campuses\/?$/, v2: () => '/api/v2/campuses' },
{ method: 'GET', v1: /^\/api\/campuses\/admin$/, v2: () => '/api/v2/campuses/admin' },
{ method: 'GET', v1: /^\/api\/campuses\/by-slug\/([^/]+)$/, v2: m => `/api/v2/campuses/by-slug/${m[1]}` },
{ method: 'PUT', v1: /^\/api\/campuses\/settings$/, v2: () => '/api/v2/campuses/enabled' },
{ method: 'PUT', v1: /^\/api\/campuses\/order$/, v2: () => '/api/v2/campuses/order' },
{ method: 'POST', v1: /^\/api\/campuses\/?$/, v2: () => '/api/v2/campuses' },
{ method: 'GET', v1: /^\/api\/campuses\/([^/]+)\/audience$/, v2: m => `/api/v2/campuses/${m[1]}/audience` },
{ method: 'PUT', v1: /^\/api\/campuses\/([^/]+)$/, v2: m => `/api/v2/campuses/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/campuses\/([^/]+)$/, v2: m => `/api/v2/campuses/${m[1]}` },
{ method: 'GET', v1: /^\/api\/users\/profile$/, v2: () => '/api/v2/me' },
{ method: 'PUT', v1: /^\/api\/users\/profile$/, v2: () => '/api/v2/me' },
{ method: 'POST', v1: /^\/api\/users\/?$/, v2: () => '/api/v2/auth/signup' },
{ method: 'POST', v1: /^\/api\/users\/login$/, v2: () => '/api/v2/auth/login' },
{ method: 'POST', v1: /^\/api\/users\/login\/2fa$/, v2: () => '/api/v2/auth/login/code' },
{ method: 'POST', v1: /^\/api\/registrations\/?$/, v2: () => '/api/v2/registrations' },
{ method: 'DELETE', v1: /^\/api\/registrations\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/cancel` },
{ method: 'POST', v1: /^\/api\/payments\/checkout$/, v2: () => '/api/v2/registrations/{id}/checkout' },
{ method: 'GET', v1: /^\/api\/tickets\/scan-preview\/([^/]+)$/, v2: m => `/api/v2/checkin/${m[1]}` },
{ method: 'POST', v1: /^\/api\/tickets\/scan\/([^/]+)$/, v2: m => `/api/v2/checkin/${m[1]}` },
];
// GET /api/events/all isn't a single event.
const NOT_AN_EVENT_ID = new Set(['all']);
/** The v2 successor of a v1 request, if it has one. Pure; exported for tests. */
export function successorOf(method: string, path: string): string | null {
for (const s of SUCCESSORS) {
if (s.method !== method) continue;
const m = path.match(s.v1);
if (!m) continue;
if (s.v1.source.startsWith('^\\/api\\/events\\/(') && NOT_AN_EVENT_ID.has(m[1])) continue;
return s.v2(m);
}
return null;
}
export const deprecationHeaders: RequestHandler = (req, res, next) => {
const successor = successorOf(req.method, req.originalUrl.split('?')[0]);
if (successor) {
res.setHeader('Deprecation', `@${DEPRECATED_AT}`);
res.setHeader('Link', `<${successor}>; rel="successor-version"`);
}
next();
};
+210
View File
@@ -0,0 +1,210 @@
/**
* Shapes for the API documentation (1.52.1): the records v1 handlers answer with, and the bodies
* they take, described once so every route shows the same thing the same way. Used as
* `documented: { body, response }` on v1Route (see RouteDef.documented) — shown in the OpenAPI
* document, not enforced; outside production anything that doesn't fit is logged.
*
* Everything is a loose object (extra fields pass, as v1 answers carry their Prisma includes),
* dates are ISO strings (as they arrive in JSON), and fields a handler may leave out are optional.
*/
import { z } from 'zod';
export const obj = <T extends z.ZodRawShape>(shape: T) => z.looseObject(shape);
export const str = z.string();
export const optStr = z.string().nullish();
export const num = z.number();
export const optNum = z.number().nullish();
export const bool = z.boolean();
export const optBool = z.boolean().nullish();
export const date = z.string().describe('Date and time (ISO 8601)');
export const optDate = date.nullish();
export const rand = z.number().describe('Rand');
export const optRand = rand.nullish();
export const anyObj = z.looseObject({});
/** A number sent as a number or as text (forms send text). */
export const numeric = z.union([z.number(), z.string()]);
export const ids = z.array(z.string()).describe('Ids');
export const channel = z.enum(['email', 'whatsapp', 'both']).describe('How to send it');
/** `{ message }`: what was done, in words for the person. */
export const message = obj({ message: str });
/** `{ ok: true }` */
export const ok = obj({ ok: z.literal(true) });
/** v1's paged lists: `{ data, total, page, limit, pages }`. */
export const v1Page = <T extends z.ZodType>(item: T) => obj({ data: z.array(item), total: num, page: num, limit: num, pages: num });
/** A `{ redirectUrl }` answer from a checkout: where to send the person to pay. */
export const checkout = obj({
checkoutId: optStr,
redirectUrl: optStr.describe('Where to send the person to pay (null when the provider takes a form)'),
provider: optStr,
form: obj({ action: str, fields: z.record(z.string(), z.string()) }).nullish().describe('A form to post (PayFast)'),
});
export const userSummary = obj({ id: str.optional(), name: str, email: optStr, phoneNumber: optStr });
export const userRef = obj({ id: str.optional(), name: str, email: optStr });
export const user = obj({
id: str,
name: str,
email: str,
phoneNumber: optStr,
role: z.enum(['admin', 'supervisor', 'staff', 'user']),
isActive: optBool,
notificationPreference: z.enum(['email', 'whatsapp', 'both']).nullish(),
homeCampusId: optStr,
createdAt: optDate,
});
export const eventRef = obj({ id: str, title: str, startDate: optDate, endDate: optDate });
export const event = obj({
id: str,
title: str,
description: optStr,
startDate: date,
endDate: date,
location: optStr,
price: optRand,
isActive: optBool,
isHidden: optBool,
imageUrl: optStr,
campusId: optStr,
scheduleType: z.enum(['single', 'sessions']).nullish(),
cashupStatus: z.enum(['open', 'closed']).nullish(),
});
export const session = obj({ id: str, label: optStr, startsAt: date, endsAt: date, cancelledAt: optDate, capacity: optNum });
export const eventOption = obj({
id: str,
eventId: str.optional(),
name: str,
description: optStr,
price: rand,
isMainTicket: bool,
stockLimit: optNum,
});
export const payment = obj({
id: str,
amount: rand.describe('Rand; a refund is negative'),
method: str.describe('cash, card, eft, other — or the wallet type for an online payment'),
provider: optStr.describe('yoco or payfast for an online payment'),
userId: str,
recordedById: optStr,
registrationId: optStr,
eventId: optStr,
isDonation: bool,
isSponsorship: optBool,
originalPaymentId: optStr.describe('A refund: the payment it refunds; a donation leg: the donation'),
externalId: optStr,
createdAt: date,
user: userSummary.nullish(),
recordedBy: userRef.nullish(),
event: eventRef.nullish(),
});
export const registrationStatus = z.enum(['pending', 'confirmed', 'partial_paid', 'paid', 'cancelled']);
export const registration = obj({
id: str,
userId: str,
eventId: str,
status: registrationStatus,
discountTotal: optRand,
totalDueComputed: optRand.describe('What it costs now, after discounts'),
createdAt: date,
user: userSummary.nullish(),
event: eventRef.nullish(),
});
export const ticket = obj({
id: str,
qrCode: str.describe('What the QR code holds'),
quantity: num.describe('People on the ticket'),
isUsed: bool,
eventId: str,
userId: str.describe('The payer'),
registrationOptionId: str,
attendeeId: optStr.describe('A named ticket: the person it\'s for'),
holderUserId: optStr,
createdAt: date,
});
export const auditEntry = obj({
id: str,
action: str,
actorRole: optStr,
targetType: optStr,
targetId: optStr,
metadata: anyObj.nullish(),
ip: optStr,
createdAt: date,
actor: userRef.nullish(),
});
/**
* What the common query parameters mean, for routes that declare them by name only (`filters(…)`
* in the resource files). The OpenAPI document uses these when a parameter has no description.
*/
export const QUERY_HELP: Record<string, string> = {
page: 'Page number, from 1',
limit: 'How many per page',
eventId: 'Only this event',
eventIds: 'These events (comma-separated ids)',
registrationId: 'Only this registration',
userId: 'Only this person',
actorId: 'Only things this person did',
sessionId: 'Multi-day events: the session (default: today\'s)',
deviceId: 'This device\'s id (offline scanning)',
from: 'On or after (ISO date or date-time)',
to: 'On or before (ISO date or date-time)',
startDate: 'On or after (ISO date)',
endDate: 'On or before (ISO date)',
before: 'Older than (ISO date-time), for the next page',
cursor: 'Where the last page ended (its `nextCursor`)',
search: 'Text to search for',
q: 'Text to search for',
status: 'Only this status',
role: 'admin, supervisor, staff or user',
isActive: '`true` or `false`',
campus: 'Campus id or slug (`all` for every campus)',
series: 'Event series slug',
format: 'File type, e.g. `pdf` or `xlsx`',
log: '`1` also writes it to the access log (a list with personal details)',
all: '`true`/`1`: include everything, not just the current ones',
medical: '`true`: include medical notes (supervisors; logged)',
type: 'Which kind',
action: 'Only this action',
sensitive: '`true`: only access to sensitive information',
version: 'A version number (default: the current one)',
provider: 'yoco or payfast',
method: 'Payment method: cash, card, eft, other',
kind: 'payment or refund',
donations: '`1`: only donations and the parts put towards registrations',
channel: 'email or whatsapp',
email: 'An email address',
phone: 'A phone number',
phoneNumber: 'A phone number',
past: '`true`: include ones that are over too',
unpaid: '`true`: only ones with money still owed',
unreviewed: '`true`: only the ones not reviewed yet',
seriesId: 'Only this event series',
registrants: '`true`: also people registered for its events in the last 12 months',
};
/** What path parameters are, when the route doesn't say. */
export const PATH_HELP: Record<string, string> = {
id: 'The id',
userId: 'The person\'s id',
attendeeId: 'The named attendee\'s id',
attachmentId: 'The attachment\'s id',
familyId: 'The family\'s id',
childId: 'The child\'s id',
slug: 'The short name in the address',
token: 'The token from the link (it is the credential)',
qrCode: 'What the ticket\'s QR code holds',
code: 'The code',
name: 'The name',
filename: 'The file name',
};
+4 -1
View File
@@ -14,6 +14,7 @@ export type ErrorCode =
| 'CONFLICT'
| 'RATE_LIMITED'
| 'READ_ONLY'
| 'UNAVAILABLE'
| 'INTERNAL';
export const ERROR_STATUS: Record<ErrorCode, number> = {
@@ -24,6 +25,8 @@ export const ERROR_STATUS: Record<ErrorCode, number> = {
CONFLICT: 409,
RATE_LIMITED: 429,
READ_ONLY: 403,
// Something this depends on (the Hub, the AI service) can't be reached right now.
UNAVAILABLE: 503,
INTERNAL: 500,
};
@@ -54,7 +57,7 @@ export function zodFields(err: ZodError): Record<string, string> {
return fields;
}
const STATUS_CODE: Record<number, ErrorCode> = { 400: 'VALIDATION_FAILED', 401: 'UNAUTHENTICATED', 403: 'FORBIDDEN', 404: 'NOT_FOUND', 409: 'CONFLICT', 423: 'READ_ONLY', 429: 'RATE_LIMITED' };
const STATUS_CODE: Record<number, ErrorCode> = { 400: 'VALIDATION_FAILED', 401: 'UNAUTHENTICATED', 403: 'FORBIDDEN', 404: 'NOT_FOUND', 409: 'CONFLICT', 423: 'READ_ONLY', 429: 'RATE_LIMITED', 503: 'UNAVAILABLE' };
const PRISMA_CODE: Record<string, ErrorCode> = { P2002: 'CONFLICT', P2025: 'NOT_FOUND', P2003: 'CONFLICT' };
type LegacyError = Error & { statusCode?: number; status?: number; code?: string };
+8 -1
View File
@@ -14,8 +14,15 @@ import './resources/events';
import './resources/me';
import './resources/registrations';
import './resources/registrationsManage';
import './resources/money';
import './resources/attendance';
import './resources/corrections';
import './resources/kidsVolunteers';
import './resources/peopleMessaging';
import './resources/adminPrivacy';
import './resources/checkin';
import './resources/auth';
import './resources/ai';
const pkg = require('../../package.json') as { version: string };
@@ -86,7 +93,7 @@ const DOCS_HTML = `<!doctype html>
sessionStorage.setItem('v2docs_token', t);
if (params.has('token')) history.replaceState(null, '', location.pathname);
document.getElementById('login').hidden = true;
var ui = SwaggerUIBundle({ spec: spec, dom_id: '#ui', deepLinking: true, persistAuthorization: false });
var ui = SwaggerUIBundle({ spec: spec, dom_id: '#ui', deepLinking: true, persistAuthorization: false, docExpansion: 'none', filter: true });
ui.preauthorizeApiKey('bearerAuth', t);
}).catch(function (e) {
document.getElementById('login').hidden = false;
+68
View File
@@ -0,0 +1,68 @@
/**
* Incoming webhooks and the links in sent messages on v2 (1.51.16). These aren't JSON API calls,
* so they're plain Express routes, mounted by app.js beside the v2 router:
*
* - `webhooksRouter` at /api/v2/webhooks: Yoco, PayFast and WAWP (WhatsApp) post here. New
* provider registrations use these URLs; the v1 paths (/api/webhooks/…, /api/whatsapp/webhook)
* are still answered by the same handlers, because providers may not follow a redirect on POST.
* - `linksRouter` at /api/v2: the one-click unsubscribe link (RFC 8058) from email headers.
* - `legacyLinkRedirects` at /api: links in messages already sent (calendar files, opening a
* one-click unsubscribe link) answer 308 with their v2 address. A mail app's one-click POST to
* the v1 address is still handled there, like a webhook.
*/
import express, { Router, type RequestHandler } from 'express';
/* eslint-disable @typescript-eslint/no-explicit-any -- the handlers are JavaScript */
const { handleYocoWebhook } = require('../controllers/webhookController') as { handleYocoWebhook: RequestHandler };
const { handleProviderWebhook } = require('../payments/webhook') as { handleProviderWebhook(id: string): RequestHandler };
const { handleWebhook: handleWhatsappWebhook } = require('../controllers/whatsappController') as { handleWebhook: RequestHandler };
const privacy = require('../controllers/privacyController') as { oneClickRedirect: RequestHandler; oneClickUnsubscribe: RequestHandler };
/** Paths whose request body is read raw (signatures are checked over the exact bytes). */
export const RAW_BODY_PREFIXES = ['/api/webhooks', '/api/v2/webhooks'];
/** WAWP sends JSON (and ?secret=…); on the webhook paths its body arrives raw. */
export const whatsappWebhook: RequestHandler[] = [(req: any, _res, next) => {
try { req.body = req.rawBody ? JSON.parse(req.rawBody) : {}; } catch { req.body = {}; }
next();
}, handleWhatsappWebhook];
export const webhooksRouter = Router();
webhooksRouter.post('/yoco', handleYocoWebhook);
webhooksRouter.post('/payfast', handleProviderWebhook('payfast'));
webhooksRouter.post('/whatsapp', ...whatsappWebhook);
export const linksRouter = Router();
linksRouter.get('/unsubscribe/:token/one-click', privacy.oneClickRedirect);
linksRouter.post('/unsubscribe/:token/one-click', express.urlencoded({ extended: false }), privacy.oneClickUnsubscribe);
/** v1 links in messages already sent → their v2 address (308 keeps the method). */
const REDIRECTS: { method: string; v1: RegExp; v2: (m: RegExpMatchArray) => string }[] = [
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/ics$/, v2: m => `/api/v2/events/${m[1]}/calendar.ics` },
{ method: 'GET', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)\/one-click$/, v2: m => `/api/v2/unsubscribe/${m[1]}/one-click` },
];
/**
* What's left of v1 (removed in 2.0), mounted at /api: WAWP's oldest webhook address (sites set
* up before 1.51.16 registered /api/whatsapp/webhook, and providers may not follow a redirect on
* POST) and a mail app's one-click unsubscribe POST to the address in an email already sent.
* /api/webhooks/… (Yoco, PayFast, WAWP) is mounted by app.js before the JSON parser.
*/
export const legacyV1Router = Router();
legacyV1Router.post('/whatsapp/webhook', handleWhatsappWebhook);
legacyV1Router.post('/privacy/unsubscribe/:token/one-click', express.urlencoded({ extended: false }), privacy.oneClickUnsubscribe);
/** Any other v1 address: gone since 2.0 (410, pointing at v2). /api/v2 misses fall through to 404. */
export const v1Removed: RequestHandler = (req, res, next) => {
if (req.path === '/v2' || req.path.startsWith('/v2/')) return next();
res.status(410).json({ code: 'API_V1_REMOVED', message: 'API v1 was removed in Cross Events 2.0. Use /api/v2 — it is described at /docs/v2.' });
};
export const legacyLinkRedirects: RequestHandler = (req, res, next) => {
const [path, query] = req.originalUrl.split('?');
for (const r of REDIRECTS) {
const m = req.method === r.method ? path.match(r.v1) : null;
if (m) return res.redirect(308, r.v2(m) + (query ? `?${query}` : ''));
}
next();
};
+14 -4
View File
@@ -1,6 +1,7 @@
import { z } from 'zod';
import { routes as allRoutes, type AnyRoute, type Access } from './route';
import { ERROR_STATUS } from './errors';
import { PATH_HELP, QUERY_HELP } from './docs';
/**
* The OpenAPI 3.1 document for /api/v2, built from the route declarations (route.ts), so it is
@@ -48,7 +49,8 @@ function parameters(def: AnyRoute) {
if (!schema) return;
const json = toSchema(schema, 'input') as { properties?: Record<string, JsonSchema>; required?: string[] };
for (const [name, prop] of Object.entries(json.properties || {})) {
const { description, ...propSchema } = prop as JsonSchema & { description?: string };
const { description: own, ...propSchema } = prop as JsonSchema & { description?: string };
const description = own || (where === 'query' ? QUERY_HELP[name] : PATH_HELP[name]);
out.push({ name, in: where, required: where === 'path' || (json.required || []).includes(name), ...(description ? { description } : {}), schema: propSchema });
}
};
@@ -57,6 +59,14 @@ function parameters(def: AnyRoute) {
return out;
}
/** A JSON body; one with nothing in it (an action that takes no input) says so. */
function requestBody(schema: JsonSchema) {
const empty = !(schema as { properties?: object }).properties || !Object.keys((schema as { properties: object }).properties).length;
return empty
? { required: true, description: 'No body needed: send an empty object.', content: { 'application/json': { schema } } }
: { required: true, content: { 'application/json': { schema } } };
}
export function buildOpenApi(opts: { version: string; serverUrl?: string; list?: AnyRoute[] }) {
const list = opts.list ?? allRoutes;
const paths: Record<string, Record<string, unknown>> = {};
@@ -73,11 +83,11 @@ export function buildOpenApi(opts: { version: string; serverUrl?: string; list?:
parameters: parameters(def),
...(def.upload
? { requestBody: { required: true, content: { 'multipart/form-data': { schema: { type: 'object', required: [def.upload.field], properties: { [def.upload.field]: { type: 'string', format: 'binary', description: def.upload.description } } } } } } }
: def.body ? { requestBody: { required: true, content: { 'application/json': { schema: toSchema(def.body, 'input') } } } } : {}),
: (def.documented?.body || def.body) ? { requestBody: requestBody(toSchema((def.documented?.body || def.body)!, 'input')) } : {}),
responses: {
[String(def.status ?? 200)]: def.file
? { description: 'The file', content: { [def.file.contentType]: { schema: { type: 'string' } } } }
: { description: 'OK', content: { 'application/json': { schema: toSchema(def.response, 'output') } } },
: { description: 'OK', content: { 'application/json': { schema: toSchema(def.documented?.response ? z.object({ data: def.documented.response }) : def.response, 'output') } } },
...(def.params || def.query || def.body ? { 400: errorResponse('Validation failed (VALIDATION_FAILED)') } : {}),
...(secured ? { 401: errorResponse('Not signed in (UNAUTHENTICATED)') } : {}),
...(secured && def.access !== 'user' ? { 403: errorResponse('Not allowed for this role (FORBIDDEN)') } : {}),
@@ -97,7 +107,7 @@ export function buildOpenApi(opts: { version: string; serverUrl?: string; list?:
'- an error is `{ "error": { "code", "message", "fields"? } }` with a stable `code`.',
'',
'Dates are ISO 8601 in UTC; amounts are in rand. Sign in through the app and send the token as `Authorization: Bearer <token>`.',
'Version 1 (`/api/…`) keeps working while the app moves over; it will be removed in 2.0.',
'Version 1 (`/api/…`) was removed in 2.0: its addresses answer `410 Gone`. See the migration guide (MIGRATING-TO-2.md).',
].join('\n'),
},
servers: [{ url: opts.serverUrl ?? '/api/v2' }],
+162
View File
@@ -0,0 +1,162 @@
/**
* Privacy and admin on v2 (1.51.15): my privacy (news consent, my data, requests, the notice),
* unsubscribe links, the privacy admin (requests, notice drafts, operators, logs, retention,
* breaches), the audit log, jobs, impersonation, site settings and first-run set-up, the Hub,
* outbound webhooks and backups. Each route runs the v1 handler (viaV1.ts) behind the same
* access checks as v1.
*/
import { z } from 'zod';
import { idParams } from '../schemas';
import { v1Route } from '../viaV1';
import * as D from '../docs';
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
const rateLimit = require('express-rate-limit') as (opts: object) => any;
const { campusIndependent } = require('../../utils/campusScope') as { campusIndependent(reason: string): any };
const privacy = { ...require('../../controllers/privacyController'), ...require('../../controllers/privacyAdminController') };
const audit = require('../../controllers/adminAuditController');
const imp = require('../../controllers/impersonationController');
const jobs = require('../../controllers/jobsController');
const settings = require('../../controllers/settingsController');
const hub = require('../../controllers/hubController');
const hooks = require('../../controllers/outboundWebhookController');
const backups = require('../../controllers/backupController');
const loose = z.looseObject({});
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
const tokenParams = z.object({ token: z.string().min(1).max(500) });
const exportLimiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false });
// ─── Documented shapes (docs.ts) ─────────────────────────────────────────────
const news = D.obj({ email: D.bool, whatsapp: D.bool });
const dataRequest = D.obj({
id: D.str, type: z.enum(['access', 'correction', 'deletion']), status: z.enum(['open', 'completed', 'rejected', 'cancelled']),
userId: D.optStr, name: D.str.optional(), email: D.str.optional(), details: D.optStr, response: D.optStr, dueAt: D.optDate.describe('30 days after it was made'),
closedAt: D.optDate, createdAt: D.date,
});
const noticeSection = D.obj({ key: D.str.optional(), title: D.str, body: D.str.describe('Text; {placeholders} are filled in') });
const operatorInput = D.obj({ name: D.str, purpose: D.str.describe('What they do for the church'), dataShared: D.optStr, location: D.optStr.describe('Country the data is kept in'), website: D.optStr, active: D.optBool, order: D.optNum });
const operator = operatorInput.extend({ id: D.str, active: D.bool, order: D.num, createdAt: D.date, updatedAt: D.date });
const logPage = <T extends z.ZodType>(row: T) => D.obj({ total: D.num, pageSize: D.num, rows: z.array(row) });
const breachInput = D.obj({
title: D.str, description: D.str, dataAffected: D.str, peopleAffected: D.numeric.nullish(), whoAffected: D.optStr, consequences: D.optStr,
actionsTaken: D.optStr, adviceToPeople: D.optStr, causedBy: D.optStr, status: D.optStr.describe('open or closed'),
discoveredAt: D.date, occurredAt: D.optDate, regulatorNotifiedAt: D.optDate.describe('When the Information Regulator was told'), peopleNotifiedAt: D.optDate,
});
const breach = breachInput.extend({ id: D.str, status: D.str, createdAt: D.date, updatedAt: D.date, createdBy: D.obj({ name: D.str }).nullish() });
const retentionState = D.obj({
policies: D.anyObj.describe('Per kind of information: how long it is kept'), definitions: D.anyObj, preview: D.anyObj.describe('What the rules would remove now'),
lastRun: D.anyObj.nullish(), related: D.obj({ medicalDays: D.num, backupCount: D.num, analyticsDays: D.num }),
});
const hubStatus = D.obj({
state: D.str.describe('active, grace, suspended, revoked, none…'), readOnly: D.bool, daysLeft: D.optNum, endsAt: D.optDate, graceEndsAt: D.optDate,
licence: D.obj({ customer: D.optStr, plan: D.optStr, status: D.optStr, licenceExpiresAt: D.optDate, graceDays: D.optNum, issuedAt: D.optDate, validUntil: D.optDate, limits: D.anyObj }).nullish(),
connected: D.bool, keyConfigured: D.bool, hubUrl: D.optStr, lastHeartbeatAt: D.optDate, lastError: D.optStr,
version: D.str.describe('This site\'s version'), latestVersion: D.optStr, updateAvailable: D.bool, urgentUpdate: D.bool, updateSnoozed: D.optBool,
releases: z.array(D.anyObj).optional().describe('What\'s new in each version since this one'), updateRequest: D.anyObj.nullish(), notices: z.array(D.anyObj).optional(),
});
const webhook = D.obj({
id: D.str, name: D.str, url: D.str, events: z.array(D.str).describe('Event types it gets'), isActive: D.bool, operatorId: D.optStr.describe('Its entry in the operator register'),
lastSuccessAt: D.optDate, lastFailureAt: D.optDate, createdAt: D.date,
});
const delivered = D.obj({ status: D.optStr.describe('succeeded, failed, retrying…'), statusCode: D.optNum, error: D.optStr });
// ─── My privacy ──────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/me/privacy', tag: 'Privacy', summary: 'My privacy settings', description: 'News consent, open requests, and whether there\'s a privacy notice to accept.', access: 'user', documented: { response: D.obj({ news, newsAskedAt: D.optDate, canWhatsApp: D.bool, notificationPreference: D.optStr, history: z.array(D.anyObj).describe('Every change to my news consent'), requests: z.array(dataRequest) }) }, v1: privacy.getMyPrivacy });
v1Route({ method: 'put', path: '/me/privacy/news', tag: 'Privacy', summary: 'Choose whether to get news', access: 'user', body: loose, documented: { body: D.obj({ email: D.optBool, whatsapp: D.optBool, source: D.optStr.describe('Where it was changed, for the consent log') }), response: D.obj({ news, newsAskedAt: D.optDate }) }, v1: privacy.updateMyNews });
v1Route({ method: 'get', path: '/me/privacy/export', tag: 'Privacy', summary: 'Download everything held about me', description: '`format=pdf` or JSON (the default).', access: 'user', middleware: [exportLimiter], query: filters('format'), file: { contentType: 'application/json' }, v1: privacy.exportMyData });
v1Route({ method: 'post', path: '/me/privacy/requests', tag: 'Privacy', summary: 'Ask for my data to be corrected or deleted', access: 'user', status: 201, body: loose, documented: { body: D.obj({ type: z.enum(['correction', 'deletion']), details: D.optStr.describe('A correction: what to change'), password: D.optStr.describe('A deletion: closes the account too') }), response: D.obj({ request: dataRequest, message: D.str }) }, v1: privacy.createMyRequest });
v1Route({ method: 'post', path: '/me/privacy/notice/accept', tag: 'Privacy', summary: 'Accept the privacy notice', access: 'user', body: loose, documented: { response: D.obj({ version: D.num.describe('The version accepted') }) }, v1: privacy.acceptMyNotice });
v1Route({ method: 'get', path: '/privacy/notice', tag: 'Privacy', summary: 'The published privacy notice', access: 'public', query: filters('version'), documented: { response: D.obj({ version: D.num.describe('0: the built-in text'), builtIn: D.optBool, current: D.optBool, publishedAt: D.optDate, effectiveDate: D.optStr, changeSummary: D.optStr, sections: z.array(D.anyObj).describe('Rendered sections'), versions: z.array(D.anyObj).describe('Every published version') }) }, v1: privacy.getNotice });
v1Route({ method: 'get', path: '/unsubscribe/:token', tag: 'Privacy', summary: 'What an unsubscribe link is for', description: 'The token in the link is the credential.', access: 'public', params: tokenParams, documented: { response: D.obj({ kind: z.enum(['account', 'address']), email: D.optStr.describe('Partly hidden'), hasPhone: D.optBool, news: D.obj({ email: D.bool, whatsapp: D.bool }).optional(), address: D.optStr.describe('kind address: the email or phone, partly hidden') }) }, v1: privacy.getUnsubscribe });
v1Route({ method: 'post', path: '/unsubscribe/:token', tag: 'Privacy', summary: 'Unsubscribe with a link', access: 'public', params: tokenParams, body: loose, documented: { body: D.obj({ channels: z.array(z.enum(['email', 'whatsapp'])).optional().describe('Default: the link\'s channel') }), response: D.message }, v1: privacy.postUnsubscribe });
// ─── Privacy admin ───────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/privacy/requests', tag: 'Privacy', summary: 'Data requests', description: '`status=open` (default), `closed` or `all`.', access: 'admin', query: filters('status', 'search', 'page', 'limit'), documented: { response: D.obj({ requests: z.array(dataRequest.extend({ overdue: D.optBool, blockers: z.array(D.anyObj).optional().describe('Upcoming registrations that stop a deletion') })), openCount: D.num, total: D.num, page: D.num, limit: D.num, pages: D.num }) }, v1: privacy.listRequests });
v1Route({ method: 'post', path: '/privacy/requests/:id/complete', tag: 'Privacy', summary: 'Mark a data request done', access: 'admin', params: idParams, body: loose, documented: { body: D.obj({ response: D.optStr.describe('Emailed to them; needed for a correction') }), response: D.obj({ request: dataRequest, anonymised: D.anyObj.nullish().describe('A deletion: what was removed') }) }, v1: privacy.completeRequest });
v1Route({ method: 'post', path: '/privacy/requests/:id/reject', tag: 'Privacy', summary: 'Turn down a data request', access: 'admin', params: idParams, body: loose, documented: { body: D.obj({ response: D.str.describe('The reason, emailed to them'), cancelled: D.optBool.describe('They withdrew it') }), response: D.obj({ request: dataRequest }) }, v1: privacy.rejectRequest });
v1Route({ method: 'get', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'The privacy notice being drafted', access: 'admin', documented: { response: D.obj({ sections: z.array(noticeSection), isDraft: D.optBool, rendered: z.array(D.anyObj).describe('The sections with placeholders filled in'), placeholders: z.array(D.str).describe('The placeholders the text can use'), versions: z.array(D.anyObj).describe('Published versions'), builtIn: z.array(noticeSection), builtInUpdates: z.array(D.anyObj).describe('Changes to the built-in text not reviewed yet') }) }, v1: privacy.getNoticeDraft });
v1Route({ method: 'put', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'Save the draft notice', access: 'admin', body: loose, documented: { body: D.obj({ sections: z.array(noticeSection) }), response: D.obj({ sections: z.array(noticeSection), rendered: z.array(D.anyObj), isDraft: z.literal(true), savedAt: D.date }) }, v1: privacy.saveNoticeDraft });
v1Route({ method: 'delete', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'Throw the draft away', access: 'admin', documented: { response: D.message }, v1: privacy.discardNoticeDraft });
v1Route({ method: 'post', path: '/privacy/notice/publish', tag: 'Privacy', summary: 'Publish the draft notice', description: 'Everyone is asked to accept it at their next sign-in.', access: 'admin', body: loose, documented: { body: D.obj({ sections: z.array(noticeSection).optional(), changeSummary: D.str.describe('Shown to people asked to accept it'), requiresAcceptance: D.optBool }), response: D.obj({ version: D.num, publishedAt: D.date, requiresAcceptance: D.bool }) }, v1: privacy.publishNotice });
v1Route({ method: 'post', path: '/privacy/notice/builtin-reviewed', tag: 'Privacy', summary: 'Mark the built-in notice as reviewed', access: 'admin', body: loose, documented: { response: D.obj({ builtInUpdates: z.array(D.anyObj) }) }, v1: privacy.markBuiltInReviewed });
v1Route({ method: 'get', path: '/privacy/operators', tag: 'Privacy', summary: 'Who processes personal information for the church', access: 'admin', documented: { response: z.array(operator) }, v1: privacy.listOperators });
v1Route({ method: 'post', path: '/privacy/operators', tag: 'Privacy', summary: 'Add an operator', access: 'admin', status: 201, body: loose, documented: { body: operatorInput, response: operator }, v1: privacy.createOperator });
v1Route({ method: 'put', path: '/privacy/operators/:id', tag: 'Privacy', summary: 'Change an operator', access: 'admin', params: idParams, body: loose, documented: { body: operatorInput.partial(), response: operator }, v1: privacy.updateOperator });
v1Route({ method: 'delete', path: '/privacy/operators/:id', tag: 'Privacy', summary: 'Remove an operator', access: 'admin', params: idParams, documented: { response: D.message }, v1: privacy.deleteOperator });
v1Route({ method: 'get', path: '/privacy/consent-log', tag: 'Privacy', summary: 'Consent given and withdrawn', access: 'admin', query: filters('type', 'page', 'q'), documented: { response: logPage(D.obj({ id: D.str, at: D.optDate, name: D.optStr })).describe('`type` news: email, channel, granted, source, ip; notice: email, version, source, ip; parental: child, event, status, signedName') }, v1: privacy.consentLog });
v1Route({ method: 'get', path: '/privacy/access-log', tag: 'Privacy', summary: 'Who looked at sensitive information', access: 'admin', query: filters('page', 'action', 'sensitive', 'actorId', 'from', 'to'), documented: { response: logPage(D.obj({ id: D.str, at: D.date, actor: D.str, actorRole: D.optStr, action: D.str, what: D.optStr, records: D.optNum, eventId: D.optStr, ip: D.optStr, sensitive: D.bool })).extend({ actions: z.record(z.string(), D.str).describe('Every action, with its label'), actors: z.array(D.obj({ id: D.str, name: D.str })) }) }, v1: privacy.accessLog });
v1Route({ method: 'get', path: '/privacy/readiness', tag: 'Privacy', summary: 'POPIA checklist', access: 'admin', documented: { response: D.obj({ score: D.num.describe('%'), passed: D.num, total: D.num, checks: z.array(D.obj({ key: D.str, label: D.str, ok: D.bool, detail: D.optStr, href: D.optStr, applies: D.bool })) }) }, v1: privacy.getReadiness });
v1Route({ method: 'get', path: '/privacy/retention', tag: 'Privacy', summary: 'How long information is kept', access: 'admin', documented: { response: retentionState }, v1: privacy.getRetention });
v1Route({ method: 'put', path: '/privacy/retention', tag: 'Privacy', summary: 'Change how long information is kept', access: 'admin', body: loose, documented: { body: D.obj({ policies: D.anyObj.describe('Per kind of information: { enabled, months }') }), response: retentionState }, v1: privacy.saveRetention });
v1Route({ method: 'post', path: '/privacy/retention/run', tag: 'Privacy', summary: 'Apply the retention rules now', access: 'admin', body: loose, documented: { response: D.obj({ result: D.anyObj.describe('What was removed or anonymised'), state: retentionState }) }, v1: privacy.runRetentionNow });
v1Route({ method: 'get', path: '/privacy/breaches', tag: 'Privacy', summary: 'Recorded data breaches', access: 'admin', documented: { response: z.array(breach) }, v1: privacy.listBreaches });
v1Route({ method: 'post', path: '/privacy/breaches', tag: 'Privacy', summary: 'Record a data breach', access: 'admin', status: 201, body: loose, documented: { body: breachInput, response: breach }, v1: privacy.createBreach });
v1Route({ method: 'put', path: '/privacy/breaches/:id', tag: 'Privacy', summary: 'Update a data breach', access: 'admin', params: idParams, body: loose, documented: { body: breachInput.partial(), response: breach }, v1: privacy.updateBreach });
// ─── Admin ───────────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/admin/audit-log', tag: 'Admin', summary: 'The audit log', access: 'admin', query: filters('page', 'limit', 'actorId', 'action', 'role', 'from', 'to', 'search'), documented: { response: D.obj({ rows: z.array(D.auditEntry), total: D.num, page: D.num, limit: D.num }) }, v1: audit.listAuditLog });
v1Route({ method: 'get', path: '/admin/jobs', tag: 'Admin', summary: 'Background jobs', access: 'admin', documented: { response: D.obj({ mode: D.str.describe('on, off or timers'), timezone: D.str, jobs: z.array(D.obj({ name: D.str, label: D.str, cron: D.optStr, enabled: D.bool, envFlag: D.optStr, nextRun: D.optDate, recent: z.array(D.anyObj).describe('Recent runs') })) }) }, v1: jobs.listJobs });
v1Route({ method: 'post', path: '/admin/jobs/:name/run', tag: 'Admin', summary: 'Run a background job now', access: 'admin', params: z.object({ name: z.string().min(1).max(100) }), body: loose, documented: { response: D.obj({ queued: D.bool, id: D.optStr, output: z.unknown().optional().describe('When it ran straight away') }) }, v1: jobs.runJobNow });
v1Route({ method: 'get', path: '/admin/impersonation-sessions', tag: 'Admin', summary: 'Times admins viewed the site as someone else', access: 'admin', query: filters('limit'), documented: { response: z.array(D.obj({ id: D.str, reason: D.optStr, startedAt: D.optDate, endedAt: D.optDate, expiresAt: D.optDate, admin: D.obj({ name: D.str, email: D.optStr }).nullish(), target: D.obj({ name: D.str, email: D.optStr }).nullish(), requests: z.array(D.obj({ method: D.str, path: D.str, blocked: D.bool, createdAt: D.date })).describe('What they did (up to 500)') })) }, v1: imp.listSessions });
v1Route({ method: 'post', path: '/admin/impersonate/:userId', tag: 'Admin', summary: 'View the site as someone (read-only)', description: 'Answers a token for a read-only session, logged.', access: 'admin', params: z.object({ userId: z.string().min(1).max(64) }), body: loose, documented: { body: D.obj({ reason: D.str.describe('Why — kept in the log') }), response: D.obj({ token: D.str.describe('A read-only sign-in token'), session: D.obj({ id: D.str, expiresAt: D.date, target: D.obj({ id: D.str, name: D.str, email: D.str, role: D.str }) }) }) }, v1: imp.startImpersonation });
v1Route({ method: 'get', path: '/me/impersonation', tag: 'Admin', summary: 'The viewing-as session I\'m in, if any', access: 'user', documented: { response: D.obj({ id: D.str, expiresAt: D.date, target: D.userRef }).nullable() }, v1: imp.currentImpersonation });
v1Route({ method: 'post', path: '/me/impersonation/end', tag: 'Admin', summary: 'Stop viewing as someone', access: 'user', body: loose, documented: { response: D.message }, v1: imp.endImpersonation });
v1Route({ method: 'post', path: '/me/impersonation/log', tag: 'Admin', summary: 'Record a page viewed while viewing as someone', access: 'user', body: loose, documented: { body: D.obj({ path: D.str }), response: z.null().describe('Always 204, no content') }, v1: imp.logPageView });
// ─── Settings and first-run set-up ───────────────────────────────────────────
v1Route({ method: 'get', path: '/settings', tag: 'Site', summary: 'Public site settings', description: 'Name, branding, contact details and what\'s switched on.', access: 'public', documented: { response: z.record(z.string(), z.string()).describe('Setting key → value: name, logo, colours, contact details, what\'s switched on') }, v1: settings.getSettings });
v1Route({ method: 'get', path: '/settings/all', tag: 'Site', summary: 'Every setting', description: 'Admins. Secrets are masked.', access: 'admin', documented: { response: z.record(z.string(), z.unknown()).describe('Setting key → value; secrets masked') }, v1: settings.getAllSettings });
v1Route({ method: 'put', path: '/settings', tag: 'Site', summary: 'Change settings', access: 'admin', body: loose, documented: { body: z.record(z.string(), z.unknown()).describe('Setting key → new value'), response: D.message }, v1: settings.updateSettings });
v1Route({ method: 'post', path: '/settings/test-smtp', tag: 'Site', summary: 'Send a test email', access: 'admin', body: loose, documented: { body: D.obj({ host: D.optStr, port: D.numeric.optional(), secure: z.union([D.bool, D.str]).optional(), user: D.optStr, pass: D.optStr, from: D.optStr }).describe('Unsaved SMTP details to try (default: the saved ones)'), response: D.message }, v1: settings.testSmtp });
v1Route({ method: 'get', path: '/setup/needed', tag: 'Site', summary: 'Does the site still need setting up?', access: 'public', documented: { response: D.obj({ needsSetup: D.bool }) }, v1: settings.needsSetup });
v1Route({ method: 'post', path: '/setup/register', tag: 'Site', summary: 'Create the first admin', description: 'Only while no account exists.', access: 'public', body: loose, documented: { body: D.obj({ adminName: D.str, adminEmail: D.str, adminPassword: D.str.describe('At least 8 characters') }), response: D.obj({ token: D.str, name: D.str, email: D.str }) }, v1: settings.setupRegister });
v1Route({ method: 'post', path: '/setup', tag: 'Site', summary: 'Finish first-run set-up', access: 'admin', body: loose, documented: { body: D.obj({ settings: z.record(z.string(), z.unknown()).describe('Setting key → value') }), response: D.message }, v1: settings.runSetup });
// ─── Hub ─────────────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/hub/status', tag: 'Site', summary: 'The connection to the provider\'s Hub', access: 'admin', documented: { response: hubStatus }, v1: hub.getStatus });
v1Route({ method: 'post', path: '/hub/connect', tag: 'Site', summary: 'Connect to the Hub', access: 'admin', body: loose, documented: { body: D.obj({ licenceKey: D.str }), response: hubStatus }, v1: hub.connect });
v1Route({ method: 'post', path: '/hub/heartbeat', tag: 'Site', summary: 'Check in with the Hub now', access: 'admin', body: loose, documented: { response: hubStatus }, v1: hub.heartbeatNow });
v1Route({ method: 'post', path: '/hub/update-request', tag: 'Site', summary: 'Ask for the update to be installed', access: 'admin', body: loose, documented: { response: hubStatus }, v1: hub.requestUpdate });
v1Route({ method: 'post', path: '/hub/update-request/cancel', tag: 'Site', summary: 'Withdraw the update request', access: 'admin', body: loose, documented: { response: hubStatus }, v1: hub.cancelUpdateRequest });
v1Route({ method: 'post', path: '/hub/update-snooze', tag: 'Site', summary: 'Remind me about the update later', access: 'admin', body: loose, documented: { body: D.obj({ snooze: D.optBool.describe('false: stop putting it off') }), response: hubStatus }, v1: hub.snoozeUpdate });
v1Route({
method: 'get', path: '/hub/notices', tag: 'Site', summary: 'Notices from the provider',
access: 'supervisor', campusScope: 'campusIndependent (notices from the provider are for the whole site)', middleware: [campusIndependent('notices from the provider are for the whole site')], documented: { response: z.array(D.obj({ id: D.str, level: D.str.describe('info, warning, critical'), title: D.str, message: D.str, startsAt: D.optDate, endsAt: D.optDate, updatedAt: D.optDate })) }, v1: hub.getNotices,
});
// ─── Outbound webhooks ───────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/outbound-webhooks', tag: 'Webhooks', summary: 'Webhooks the site sends', access: 'admin', documented: { response: D.obj({ eventTypes: z.array(D.anyObj).describe('Every event type, with an example body'), keepDays: D.num.describe('How long deliveries are kept'), endpoints: z.array(webhook.extend({ lastWeek: z.record(z.string(), z.number()).describe('Status → deliveries in the last 7 days') })) }) }, v1: hooks.list });
v1Route({ method: 'post', path: '/outbound-webhooks', tag: 'Webhooks', summary: 'Add a webhook', description: 'Answers its signing secret once.', access: 'admin', status: 201, body: loose, documented: { body: D.obj({ name: D.str, url: D.str.describe('https:// address'), events: z.array(D.str) }), response: webhook.extend({ secret: D.str.describe('For checking signatures — shown only now') }) }, v1: hooks.create });
v1Route({ method: 'post', path: '/outbound-webhook-deliveries/:id/resend', tag: 'Webhooks', summary: 'Send a delivery again', access: 'admin', params: idParams, body: loose, documented: { response: delivered }, v1: hooks.resend });
v1Route({ method: 'patch', path: '/outbound-webhooks/:id', tag: 'Webhooks', summary: 'Change a webhook', access: 'admin', params: idParams, body: loose, documented: { body: D.obj({ name: D.optStr, url: D.optStr, events: z.array(D.str).optional(), isActive: D.optBool }), response: webhook }, v1: hooks.update });
v1Route({ method: 'delete', path: '/outbound-webhooks/:id', tag: 'Webhooks', summary: 'Remove a webhook', access: 'admin', params: idParams, documented: { response: D.ok }, v1: hooks.remove });
v1Route({ method: 'post', path: '/outbound-webhooks/:id/rotate-secret', tag: 'Webhooks', summary: 'Give a webhook a new signing secret', access: 'admin', params: idParams, body: loose, documented: { response: D.obj({ secret: D.str.describe('Shown only now; the old one stops working') }) }, v1: hooks.rotateSecret });
v1Route({ method: 'post', path: '/outbound-webhooks/:id/test', tag: 'Webhooks', summary: 'Send a test delivery', access: 'admin', params: idParams, body: loose, documented: { response: delivered }, v1: hooks.test });
v1Route({ method: 'get', path: '/outbound-webhooks/:id/deliveries', tag: 'Webhooks', summary: 'A webhook\'s recent deliveries', access: 'admin', params: idParams, query: filters('page', 'limit', 'status', 'before'), documented: { response: z.array(D.obj({ id: D.str, eventId: D.str, type: D.str.describe('The event type'), payload: z.unknown(), status: D.str.describe('pending, sending, retrying, succeeded, failed'), attempts: D.num, nextAttemptAt: D.optDate, lastStatusCode: D.optNum, lastError: D.optStr, createdAt: D.date, deliveredAt: D.optDate })) }, v1: hooks.deliveries });
// ─── Backups ─────────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/backups', tag: 'Admin', summary: 'Database backups', access: 'admin', documented: { response: z.array(D.obj({ filename: D.str, size: D.num.describe('Bytes'), createdAt: D.date })) }, v1: backups.getBackups });
v1Route({ method: 'post', path: '/backups/run', tag: 'Admin', summary: 'Back up the database now', access: 'admin', body: loose, documented: { response: D.anyObj.describe('The backup made') }, v1: backups.triggerBackup });
v1Route({ method: 'get', path: '/backups/:filename/download', tag: 'Admin', summary: 'Download a backup', access: 'admin', params: z.object({ filename: z.string().min(1).max(200) }), file: { contentType: 'application/octet-stream' }, v1: backups.downloadBackup });
// ─── Yoco and PayFast settings (admins; their incoming webhooks are separate) ─
const yoco = require('../../controllers/yocoController');
const payfast = require('../../controllers/payfastController');
v1Route({ method: 'get', path: '/payments/yoco/config', tag: 'Payments', summary: 'Yoco settings', description: 'Keys are masked.', access: 'admin', documented: { response: D.obj({ secretKeyMasked: D.str, secretKeySource: D.optStr.describe('settings or env'), mode: D.optStr.describe('test or live'), webhookSecretMasked: D.str, webhookSecretSource: D.optStr, webhookId: D.str, webhookUrl: D.str, configured: D.bool }) }, v1: yoco.getConfig });
v1Route({ method: 'put', path: '/payments/yoco/config', tag: 'Payments', summary: 'Change Yoco settings', access: 'admin', body: loose, documented: { body: D.obj({ secretKey: D.optStr.describe('sk_test_… or sk_live_…'), webhookSecret: D.optStr.describe('whsec_…') }), response: D.message }, v1: yoco.updateConfig });
v1Route({ method: 'get', path: '/payments/yoco/webhooks', tag: 'Payments', summary: 'Webhooks registered with Yoco', access: 'admin', documented: { response: D.obj({ webhooks: z.array(D.obj({ id: D.str, name: D.optStr, url: D.str, mode: D.optStr, isActive: D.bool, pointsHere: D.bool })), defaultUrl: D.str }) }, v1: yoco.getWebhooks });
v1Route({ method: 'post', path: '/payments/yoco/webhooks', tag: 'Payments', summary: 'Register a webhook with Yoco', access: 'admin', body: loose, documented: { body: D.obj({ name: D.optStr, url: D.optStr.describe('Default: this site\'s webhook address') }), response: D.obj({ message: D.str, webhook: D.obj({ id: D.str, name: D.optStr, url: D.str, mode: D.optStr }), webhookSecretMasked: D.str }) }, v1: yoco.createWebhook });
v1Route({ method: 'delete', path: '/payments/yoco/webhooks/:id', tag: 'Payments', summary: 'Remove a webhook from Yoco', access: 'admin', params: z.object({ id: z.string().min(1).max(200) }), documented: { response: D.obj({ message: D.str, clearedSecret: D.bool }) }, v1: yoco.removeWebhook });
v1Route({ method: 'get', path: '/payments/payfast/config', tag: 'Payments', summary: 'PayFast settings', description: 'The passphrase is masked.', access: 'admin', documented: { response: D.obj({ merchantId: D.str, merchantKey: D.str, passphraseMasked: D.str, hasPassphrase: D.bool, mode: z.enum(['sandbox', 'live']), notifyUrl: D.str, notifyUrlProblem: D.optStr.describe('Why PayFast couldn\'t reach it'), configured: D.bool }) }, v1: payfast.getConfig });
v1Route({ method: 'put', path: '/payments/payfast/config', tag: 'Payments', summary: 'Change PayFast settings', access: 'admin', body: loose, documented: { body: D.obj({ merchantId: D.optStr, merchantKey: D.optStr, passphrase: D.optStr.describe('At most 32 characters'), mode: z.enum(['sandbox', 'live']).optional() }), response: D.message }, v1: payfast.updateConfig });
v1Route({ method: 'post', path: '/payments/payfast/test', tag: 'Payments', summary: 'Check the PayFast details work', access: 'admin', body: loose, documented: { response: D.message }, v1: payfast.testConnection });
+189
View File
@@ -0,0 +1,189 @@
import { z } from 'zod';
import { defineRoute } from '../route';
import { single } from '../paging';
import { id } from '../schemas';
import { notFound } from '../errors';
import { prisma } from '../legacy';
import { aiStatus, getAiSettings, saveAiSettings, usageThisMonth, HELPERS, clearAiStatusCache } from '../../ai';
import { draftEvent, summariseReport, draftMessage, summariseFeedback, improveText, REPORTS_WITHOUT_PEOPLE, type FeedbackQuestion } from '../../ai/helpers';
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
const { campusIndependent, scopeBody } = require('../../utils/campusScope') as { campusIndependent(reason: string): any; scopeBody(targets: [string, string][], opts?: object): any };
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
const { logDataAccess } = require('../../utils/accessLog') as { logDataAccess(req: unknown, entry: { action: string; eventId?: string; records?: number }): Promise<void> };
const feedbackUtils = require('../../utils/feedback') as { summarise(fields: unknown[], answers: { fieldId: string; value: string }[]): any[] };
const helperFlags = z.object(Object.fromEntries(HELPERS.map(h => [h, z.boolean()])) as Record<(typeof HELPERS)[number], z.ZodBoolean>);
const status = z.object({
enabled: z.boolean().describe('false: hide the AI buttons (the licence has no AI, or the site isn\'t connected to the Hub)'),
capReached: z.boolean().describe('true: show the buttons disabled until `renewsOn`'),
available: z.boolean().describe('false: the Hub couldn\'t be reached just now'),
capCents: z.number().describe('The monthly allowance, in cents'),
usedCents: z.number().describe('Used this month, in cents (fractions of a cent)'),
usedPercent: z.number(),
renewsOn: z.string().nullable().describe('YYYY-MM-DD'),
helpers: helperFlags.describe('Each helper switched on or off in Site settings → AI'),
});
const settings = z.object({
helpers: helperFlags,
dailyLimitPerPerson: z.number().int().min(0).max(1000).describe('0 = no limit'),
});
const use = z.object({ uses: z.number().int(), chargedCents: z.number() });
export const getAiStatus = defineRoute({
method: 'get',
path: '/ai/status',
tag: 'AI',
summary: 'Whether to show the AI buttons',
description: 'Hidden when `enabled` is false; shown but disabled when `capReached` (until `renewsOn`, or until the allowance is raised). Cached for a minute; every AI call refreshes it.',
access: 'supervisor',
campusScope: 'campusIndependent (the site\'s AI allowance)',
middleware: [campusIndependent('the site\'s AI allowance')],
response: single(status),
async handler() {
return { data: await aiStatus() };
},
});
export const getAiUsage = defineRoute({
method: 'get',
path: '/ai/usage',
tag: 'AI',
summary: 'AI use this month',
description: 'For Site settings → AI: the allowance, this site\'s switches, and this month\'s use per person, per helper and per day.',
access: 'admin',
response: single(z.object({
status,
settings,
people: z.array(use.extend({ userId: z.string().nullable(), name: z.string() })),
helpers: z.array(use.extend({ helper: z.string() })),
days: z.array(use.extend({ day: z.string() })),
})),
async handler() {
clearAiStatusCache();
const [s, settingsNow, usage] = await Promise.all([aiStatus({ fresh: true }), getAiSettings(), usageThisMonth()]);
return { data: { status: s, settings: settingsNow, ...usage } };
},
});
export const updateAiSettings = defineRoute({
method: 'put',
path: '/ai/settings',
tag: 'AI',
summary: 'Switch helpers on or off, and set a daily limit',
access: 'admin',
body: z.object({ helpers: helperFlags.partial().optional(), dailyLimitPerPerson: z.number().int().min(0).max(1000).optional() }),
response: single(settings),
async handler({ body, user, req }) {
return { data: await saveAiSettings(body, user!, getClientIp(req)) };
},
});
// ─── The helpers ──────────────────────────────────────────────────────────────
// Each answer is a suggestion for a person to check: nothing is saved or sent by these routes.
const withStatus = <T extends z.ZodType>(output: T) => single(z.object({ output, status }));
const HELPER_SCOPE = 'campusIndependent (an AI suggestion from what is sent; nothing is read or changed)';
const helperAccess = { access: 'supervisor' as const, campusScope: HELPER_SCOPE, middleware: [campusIndependent('an AI suggestion from what is sent; nothing is read or changed')] };
const AI_DESCRIPTION = 'Goes through the Cross Events Hub, which counts it against the site\'s monthly AI allowance. Errors carry a `reason`: AI_OFF, AI_HELPER_OFF, AI_CAP_REACHED, AI_DAILY_LIMIT, AI_REFUSED, AI_INCOMPLETE, AI_UNAVAILABLE.';
const textList = z.array(z.string());
const eventDraft = z.object({
title: z.string(), summary: z.string(), description: z.string(),
startDate: z.string(), startTime: z.string(), endDate: z.string(), endTime: z.string(),
location: z.string(), capacity: z.number(), needsParentalConsent: z.boolean(), ageGroup: z.string(),
ticketOptions: z.array(z.object({ name: z.string(), priceRand: z.number(), description: z.string(), earlyBirdPriceRand: z.number(), earlyBirdUntil: z.string() })),
sessions: z.array(z.object({ date: z.string(), startTime: z.string(), endTime: z.string(), label: z.string() })),
costs: z.array(z.object({ item: z.string(), basis: z.string(), amountRand: z.number() })),
checks: textList,
});
export const aiDraftEvent = defineRoute({
method: 'post', path: '/ai/draft-event', tag: 'AI', summary: 'Draft an event from a description',
description: `Fills in the event form from the organiser's notes. \`warnings\` are checked in code (a weekday that doesn't match its date, costs that a full event can't cover); \`draft.checks\` are the AI's own. ${AI_DESCRIPTION}`,
...helperAccess,
body: z.object({ notes: z.string().trim().min(10).max(4000) }),
response: withStatus(z.object({ draft: eventDraft, warnings: textList, totalCostRand: z.number(), breakEvenPlaces: z.number().nullable() })),
async handler({ body, user }) {
const { status: s, ...output } = await draftEvent(user!.id, body.notes);
return { data: { output, status: s } };
},
});
export const aiSummariseReport = defineRoute({
method: 'post', path: '/ai/summarise-report', tag: 'AI', summary: 'Summarise a report',
description: `A few plain-language insights on a report as it is on screen. Only reports without people in them: ${REPORTS_WITHOUT_PEOPLE.join(', ')}. ${AI_DESCRIPTION}`,
...helperAccess,
body: z.object({
reportKey: z.enum(REPORTS_WITHOUT_PEOPLE),
title: z.string().max(300), subtitle: z.string().max(500).optional(),
stats: z.array(z.object({ label: z.string().max(200), value: z.union([z.string().max(200), z.number()]) })).max(40).optional(),
columns: z.array(z.string().max(200)).max(40),
rows: z.array(z.array(z.union([z.string().max(500), z.number(), z.null()]).transform(v => v ?? ''))).max(5000),
}),
response: withStatus(z.object({ headline: z.string(), insights: z.array(z.object({ title: z.string(), detail: z.string() })), watchOut: textList })),
async handler({ body, user }) {
const { summary, status: s } = await summariseReport(user!.id, body);
return { data: { output: summary, status: s } };
},
});
export const aiDraftMessage = defineRoute({
method: 'post', path: '/ai/draft-message', tag: 'AI', summary: 'Draft an email or WhatsApp message',
description: `Turns a short note into a message, using {{name}} and the event placeholders. With \`eventId\`, the event's title, start and place are given to the AI as facts. ${AI_DESCRIPTION}`,
access: 'supervisor', campusScope: 'scopeBody(eventId)', middleware: [scopeBody([['eventId', 'event']])],
body: z.object({ channel: z.enum(['email', 'whatsapp']), note: z.string().trim().min(3).max(3000), eventId: id.optional() }),
response: withStatus(z.object({ subject: z.string(), body: z.string() })),
async handler({ body, user }) {
let event = null;
if (body.eventId) {
const e = await prisma.event.findUnique({ where: { id: body.eventId }, select: { title: true, startDate: true, location: true } });
if (!e) throw notFound('The event');
event = { title: e.title, start: e.startDate.toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg', weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' }), location: e.location };
}
const { message, status: s } = await draftMessage(user!.id, { channel: body.channel, note: body.note, event });
return { data: { output: message, status: s } };
},
});
export const aiSummariseFeedback = defineRoute({
method: 'post', path: '/ai/summarise-feedback', tag: 'AI', summary: 'Summarise an event\'s feedback',
description: `Themes, what people liked, what to improve and suggestions, from the event's feedback. Sent without who answered; contact details in comments are kept back. Recorded in the access log. ${AI_DESCRIPTION}`,
access: 'supervisor', campusScope: 'scopeBody(eventId)', middleware: [scopeBody([['eventId', 'event']])],
body: z.object({ eventId: id }),
response: withStatus(z.object({
overall: z.string(), liked: z.array(z.object({ theme: z.string(), mentions: z.number() })), improve: z.array(z.object({ theme: z.string(), mentions: z.number() })),
quotes: textList, nextTime: textList, responses: z.number(),
})),
async handler({ body, user, req }) {
const form = await prisma.feedbackForm.findUnique({
where: { eventId: body.eventId },
include: { event: { select: { title: true } }, fields: { orderBy: { order: 'asc' } }, responses: { include: { answers: true } }, _count: { select: { invites: true } } },
});
if (!form) throw notFound('Feedback for this event');
if (!form.responses.length) throw Object.assign(notFound('Feedback answers for this event'), { reason: 'AI_NOTHING_TO_SUMMARISE' });
// Never who answered: only the answers.
const answers = form.responses.flatMap(r => r.answers.map(a => ({ fieldId: a.fieldId, value: a.value })));
const questions: FeedbackQuestion[] = feedbackUtils.summarise(form.fields, answers).map((q: any) => ({
label: q.label, type: q.type, answered: q.answered, average: q.average, counts: q.counts, texts: q.texts?.map((t: { value: string }) => t.value),
}));
await logDataAccess(req, { action: 'ai_feedback', eventId: body.eventId, records: form.responses.length });
const { summary, status: s } = await summariseFeedback(user!.id, { event: form.event, responses: form.responses.length, invited: form._count.invites, questions });
return { data: { output: { ...summary, responses: form.responses.length }, status: s } };
},
});
export const aiImproveText = defineRoute({
method: 'post', path: '/ai/improve-text', tag: 'AI', summary: 'Improve a piece of text',
description: `Fixes spelling and grammar and makes it clearer (\`improve\`), shorter (\`shorter\`) or only fixes mistakes (\`fix\`), keeping every fact. ${AI_DESCRIPTION}`,
...helperAccess,
body: z.object({ text: z.string().trim().min(3).max(8000), mode: z.enum(['improve', 'shorter', 'fix']).default('improve'), where: z.enum(['description', 'banner', 'message']).default('description') }),
response: withStatus(z.object({ text: z.string(), changes: textList })),
async handler({ body, user }) {
const { result, status: s } = await improveText(user!.id, body);
return { data: { output: result, status: s } };
},
});
+230
View File
@@ -0,0 +1,230 @@
/**
* Tickets and attendance on v2 (1.51.12): tickets (mine, one, sending, transfers, the staff
* lists, generating), scan locks (offline scanning), the attendance register and certificates,
* and allocations (groups, rooms, teams). Each route runs the v1 handler (viaV1.ts) behind the
* same access and campus checks as v1. Scanning itself is /checkin (1.51.2).
*/
import { z } from 'zod';
import { id, idParams } from '../schemas';
import { v1Route } from '../viaV1';
import * as D from '../docs';
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
const cs = require('../../utils/campusScope') as {
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown; ownerOk?: boolean; nameCampus?: boolean }): any;
scopeBody(targets: [string, string][], opts?: { need?: 'view' | 'manage'; orphan?: 'pass' | 'deny' }): any;
scopeList: any;
campusIndependent(reason: string): any;
};
const t = require('../../controllers/ticketController');
const locks = require('../../controllers/scanLockController');
const att = require('../../controllers/attendanceController');
const alloc = require('../../controllers/allocationController');
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
const asEvent = (ctx: any) => ({ params: { ...ctx.req.params, eventId: ctx.params.id } });
const loose = z.looseObject({});
const tokenParams = z.object({ token: z.string().min(1).max(200) });
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
// ─── Documented shapes (docs.ts) ─────────────────────────────────────────────
const usage = D.obj({ id: D.str.optional(), scannedAt: D.date, quantityRedeemed: D.num, sessionId: D.optStr, manual: D.optBool.describe('Ticked present by hand') });
const ticketFull = D.ticket.extend({
usages: z.array(usage).optional().describe('Check-ins'),
event: D.event.partial().nullish(),
user: D.userSummary.nullish().describe('The payer'),
attendee: D.obj({ name: D.str }).nullish(),
registrationOption: D.obj({ eventOption: D.eventOption.partial().nullish(), variant: D.obj({ name: D.str }).nullish(), registration: D.registration.partial().nullish() }).nullish(),
});
const sent = D.obj({ message: D.str, ticketIds: D.ids.optional().describe('The tickets sent') });
const lockView = D.obj({
id: D.str, eventId: D.str, sectionId: D.optStr, sectionName: D.optStr, deviceId: D.str, deviceName: D.optStr,
user: D.userRef.nullish().describe('Who holds it'), lockedAt: D.date, snapshotAt: D.optDate, lastSyncAt: D.optDate, releasedAt: D.optDate,
});
const transferInput = D.obj({
name: D.str.describe('Who it goes to'), email: D.optStr, phone: D.optStr.describe('Email or phone needed'),
dateOfBirth: D.optDate.describe('Youth events'), gender: D.optStr, parentName: D.optStr, parentEmail: D.optStr, parentPhone: D.optStr,
});
const certificate = D.obj({ id: D.str, code: D.str.describe('For checking it at /verify'), name: D.str, issuedAt: D.date, sessionsAttended: D.optNum, totalSessions: D.optNum, event: D.eventRef.partial().nullish() });
const registerRow = D.obj({
ticketId: D.str, bookedSessions: D.ids.nullable().describe('Picked/ticked sessions (null: all)'), sessionTickets: z.record(z.string(), z.string()).nullable(),
name: D.str, bookedBy: D.str, email: D.str, phone: D.str, option: D.str, variant: D.optStr, quantity: D.num,
checkIns: z.record(z.string(), z.number()).describe('Session id (or `event`) → people checked in'),
attended: D.num, total: D.num, completed: D.bool.describe('Meets the certificate rule'), certificate: D.anyObj.nullish(),
});
const groupInput = D.obj({
name: D.optStr, capacity: D.numeric.nullish(), gender: z.enum(['male', 'female', 'mixed']).nullish(), leaderUserId: D.optStr.describe('A staff member'),
minAge: D.numeric.nullish(), maxAge: D.numeric.nullish(), ratio: D.numeric.nullish().describe('Children per leader'),
});
const group = D.obj({ id: D.str, setId: D.str, name: D.str, capacity: D.optNum, gender: D.optStr, leaderUserId: D.optStr, minAge: D.optNum, maxAge: D.optNum, ratio: D.optNum, order: D.num });
const allocationSet = D.obj({ id: D.str, eventId: D.str, name: D.str, showOnTicket: D.bool, order: D.num });
const member = D.obj({ attendeeId: D.str.optional(), name: D.str, gender: D.optStr, dateOfBirth: D.optDate, groupId: D.optStr });
const ticketOwner = cs.scopeEvent({ resolve: 'ticket', ownerOk: true });
const qrScope = cs.scopeEvent({ resolve: 'ticketQr', from: (req: any) => req.params.qrCode, nameCampus: true });
// ─── Tickets ─────────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/me/tickets', tag: 'Tickets', summary: 'My tickets', description: 'Including ones transferred to me.', access: 'user', documented: { response: z.array(ticketFull) }, v1: t.getUserTickets });
v1Route({
method: 'post', path: '/me/tickets/email', tag: 'Tickets', summary: 'Send me my tickets',
description: 'For a registration (`registrationId`) or chosen tickets (`ticketIds`), by email and/or WhatsApp (`channel`).',
access: 'user', body: z.looseObject({ registrationId: id.optional(), ticketIds: z.array(id).max(500).optional(), channel: z.string().max(20).optional() }), documented: { response: sent }, v1: t.emailTickets,
});
v1Route({
method: 'get', path: '/tickets/scans/recent', tag: 'Tickets', summary: 'Recent scans', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'limit'), documented: { response: z.array(D.obj({ id: D.str, scannedAt: D.date, quantityRedeemed: D.num, scannedBy: D.userRef.nullish(), ticket: D.obj({ id: D.str, event: D.obj({ title: D.str }), registrationOption: D.anyObj.nullish() }) })) }, v1: t.getRecentScans,
});
v1Route({
method: 'get', path: '/tickets/scans/stats', tag: 'Tickets', summary: 'Scan counts', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId'), documented: { response: D.obj({ totalToday: D.num, myToday: D.num, lastHour: D.num, byStaff: z.array(D.obj({ scannedById: D.str, name: D.str, count: D.num })) }) }, v1: t.getScanStats,
});
v1Route({
method: 'post', path: '/tickets/send', tag: 'Tickets', summary: 'Send tickets to someone',
description: 'Staff: a registration\'s tickets (or chosen ones) to an email address or phone number.',
access: 'staff', campusScope: 'scopeBody(registrationId, ticketIds)', middleware: [cs.scopeBody([['registrationId', 'registration'], ['ticketIds', 'ticket']])], body: loose, documented: { body: D.obj({ registrationId: D.optStr, ticketIds: D.ids.optional().describe('Or registrationId'), channel: D.channel, overrideEmail: D.optStr.describe('Send here instead'), overridePhone: D.optStr }), response: sent }, v1: t.sendTicketsTo,
});
v1Route({
method: 'post', path: '/tickets/generate', tag: 'Tickets', summary: 'Issue a registration\'s tickets',
access: 'supervisor', campusScope: 'scopeBody(registrationId)', middleware: [cs.scopeBody([['registrationId', 'registration']])], body: z.looseObject({ registrationId: id }), documented: { response: D.obj({ message: D.str, tickets: z.array(ticketFull) }) }, v1: t.generateTickets,
});
v1Route({
method: 'post', path: '/tickets/email-sent', tag: 'Tickets', summary: 'Mark tickets as emailed', access: 'admin', body: loose, documented: { body: D.obj({ ticketIds: D.ids }), response: D.message }, v1: t.markTicketsAsEmailSent,
});
v1Route({
method: 'get', path: '/tickets', tag: 'Tickets', summary: 'Every ticket', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'page', 'limit'), documented: { response: D.v1Page(ticketFull) }, v1: t.getTickets,
});
v1Route({
method: 'get', path: '/tickets/qr/:qrCode', tag: 'Tickets', summary: 'The ticket for a QR code',
description: 'Without checking it in (that\'s POST /checkin/{qrCode}).',
access: 'staff', campusScope: 'scopeEvent(ticketQr)', middleware: [qrScope], params: z.object({ qrCode: z.string().min(1).max(200) }), documented: { response: ticketFull }, v1: t.getTicketByQrCode,
});
v1Route({
method: 'get', path: '/events/:id/tickets', tag: 'Tickets', summary: 'An event\'s tickets',
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: z.array(ticketFull) }, v1: t.getTicketsByEvent,
});
v1Route({
method: 'get', path: '/ticket-transfers/:token', tag: 'Tickets', summary: 'A ticket someone is giving you',
description: 'What the transfer link shows (the token is the credential).', access: 'public', params: tokenParams, documented: { response: D.obj({ status: D.str.describe('pending, accepted, declined, cancelled, expired'), toName: D.str, expiresAt: D.date, fromName: D.str, event: D.event.partial(), option: D.str }) }, v1: t.getTransferOffer,
});
v1Route({ method: 'post', path: '/ticket-transfers/:token/accept', tag: 'Tickets', summary: 'Accept a ticket', access: 'public', params: tokenParams, body: loose, documented: { response: D.obj({ message: D.str, ticketId: D.str }) }, v1: t.acceptTransferOffer });
v1Route({ method: 'post', path: '/ticket-transfers/:token/decline', tag: 'Tickets', summary: 'Decline a ticket', access: 'public', params: tokenParams, body: loose, documented: { response: D.message }, v1: t.declineTransferOffer });
v1Route({
method: 'get', path: '/tickets/:id', tag: 'Tickets', summary: 'One ticket', access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, documented: { response: ticketFull }, v1: t.getTicketById,
});
v1Route({
method: 'post', path: '/tickets/:id/transfer', tag: 'Tickets', summary: 'Give a ticket to someone',
description: 'Sends them a link to accept it; the ticket is theirs once they do.',
access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, body: loose, documented: { body: transferInput, response: D.obj({ message: D.str, requestId: D.str, expiresAt: D.date }) }, v1: t.transferTicketHandler,
});
v1Route({
method: 'delete', path: '/tickets/:id/transfer', tag: 'Tickets', summary: 'Cancel a ticket transfer', access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, documented: { response: D.message }, v1: t.cancelTransferHandler,
});
v1Route({
method: 'get', path: '/tickets/:id/transfers', tag: 'Tickets', summary: 'A ticket\'s transfers', access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, documented: { response: z.array(D.obj({ id: D.str, ticketId: D.str, fromUserId: D.optStr, toUserId: D.optStr, createdAt: D.date })) }, v1: t.getTicketTransfers,
});
// ─── Scan locks (offline scanning) ───────────────────────────────────────────
v1Route({
method: 'get', path: '/scan-locks', tag: 'Check-in', summary: 'Devices scanning offline for an event',
access: 'staff', campusScope: 'scopeEvent(query.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.query.eventId })], query: z.looseObject({ eventId: id }), documented: { response: z.array(lockView) }, v1: locks.listLocks,
});
v1Route({
method: 'post', path: '/scan-locks', tag: 'Check-in', summary: 'Take tickets offline on this device',
description: 'Locks the chosen tickets to this device until it syncs or releases them.',
access: 'staff', campusScope: 'scopeEvent(body.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.body?.eventId })], status: 201, body: z.looseObject({ eventId: id }), documented: { body: D.obj({ eventId: D.str, sectionId: D.optStr.describe('Just this section (default: the whole event)'), deviceId: D.str, deviceName: D.optStr }), response: lockView }, v1: locks.createLock,
});
v1Route({
method: 'get', path: '/scan-locks/reports', tag: 'Check-in', summary: 'Offline sync reports to review',
access: 'supervisor', campusScope: 'scopeEvent(query.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.query.eventId })], query: z.looseObject({ eventId: id, unreviewed: z.string().max(10).optional() }), documented: { response: z.array(D.obj({ id: D.str, accepted: D.optNum, duplicates: D.optNum, conflicts: z.unknown().optional(), reviewedAt: D.optDate, createdAt: D.date, scanLock: lockView })) }, v1: locks.listReports,
});
v1Route({
method: 'post', path: '/scan-locks/reports/:id/review', tag: 'Check-in', summary: 'Mark a sync report reviewed',
access: 'supervisor', campusScope: 'scopeEvent(syncReport)', middleware: [cs.scopeEvent({ resolve: 'syncReport' })], params: idParams, body: loose, documented: { response: D.obj({ id: D.str, reviewedAt: D.date, reviewedById: D.str }) }, v1: locks.reviewReport,
});
v1Route({
method: 'get', path: '/scan-locks/:id/snapshot', tag: 'Check-in', summary: 'The tickets a device took offline',
access: 'staff', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, query: filters('deviceId'), documented: { response: D.obj({ lock: lockView, snapshotAt: D.date, session: D.obj({ id: D.str, label: D.str }).nullable(), noSessionToday: D.bool, tickets: z.array(D.obj({ id: D.str, qrCode: D.str, holder: D.str, bookedBy: D.str, option: D.str, variant: D.optStr, quantity: D.num, remaining: D.num, consentStatus: D.str })) }) }, v1: locks.getSnapshot,
});
v1Route({
method: 'post', path: '/scan-locks/:id/sync', tag: 'Check-in', summary: 'Upload scans made offline',
access: 'staff', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, body: loose, documented: { body: D.obj({ deviceId: D.str, scans: z.array(D.obj({ clientScanId: D.str.describe('The device\'s id for the scan (repeats are ignored)'), qrCode: D.str, qty: D.numeric.optional(), scannedAt: D.optDate, sessionId: D.optStr })).max(2000) }), response: D.obj({ accepted: D.num, duplicates: D.num, conflicts: z.array(D.anyObj).describe('Scans that clashed with online check-ins'), reportId: D.str, released: D.bool }) }, v1: locks.syncLock,
});
v1Route({
method: 'post', path: '/scan-locks/:id/force-release', tag: 'Check-in', summary: 'Release a lost device\'s tickets',
access: 'supervisor', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, body: loose, documented: { response: D.message }, v1: locks.forceRelease,
});
v1Route({
method: 'delete', path: '/scan-locks/:id', tag: 'Check-in', summary: 'Release tickets back online',
access: 'staff', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, body: z.looseObject({ deviceId: z.string().max(200).optional() }), documented: { response: D.message }, v1: locks.releaseLock,
});
// ─── Attendance and certificates ─────────────────────────────────────────────
v1Route({
method: 'get', path: '/certificates/verify/:code', tag: 'Attendance', summary: 'Check a certificate is genuine',
access: 'public', params: z.object({ code: z.string().min(1).max(100) }), documented: { response: D.obj({ valid: D.bool, revoked: D.optBool, name: D.optStr, event: D.optStr, completedOn: D.optDate, issuedAt: D.optDate }) }, v1: att.verifyCertificate,
});
v1Route({ method: 'get', path: '/me/certificates', tag: 'Me', summary: 'My certificates', access: 'user', documented: { response: z.array(certificate) }, v1: att.myCertificates });
v1Route({
method: 'post', path: '/certificates/preview', tag: 'Attendance', summary: 'Preview a certificate template',
access: 'supervisor', campusScope: 'campusIndependent (previews a template from the request body)', middleware: [cs.campusIndependent('previews a certificate template from the request body')], body: loose, file: { contentType: 'application/pdf' }, documented: { body: D.obj({ title: D.optStr.describe('Event title to show'), config: D.obj({ template: D.optStr.describe('classic, modern, elegant, simple'), title: D.optStr, body: D.optStr.describe('{name} and {event} are filled in') }).optional() }) }, v1: att.previewCertificate,
});
v1Route({
method: 'get', path: '/certificates/:id/pdf', tag: 'Attendance', summary: 'Download a certificate',
description: 'Its owner, or staff.', access: 'user', params: idParams, file: { contentType: 'application/pdf' }, v1: att.downloadCertificate,
});
v1Route({
method: 'post', path: '/certificates/:id/revoke', tag: 'Attendance', summary: 'Withdraw a certificate',
access: 'supervisor', campusScope: 'scopeEvent(certificate)', middleware: [cs.scopeEvent({ resolve: 'certificate' })], params: idParams, body: loose, documented: { response: D.ok }, v1: att.revokeCertificate,
});
v1Route({
method: 'get', path: '/events/:id/attendance', tag: 'Attendance', summary: 'An event\'s attendance register',
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: D.obj({ event: D.event.partial().extend({ certificatesEnabled: D.optBool, certificateConfig: D.anyObj.nullish() }), columns: z.array(D.obj({ id: D.str.describe('Session id, or `event`'), label: D.str })), rows: z.array(registerRow), totals: z.record(z.string(), z.number()) }) }, v1: att.getRegister,
});
v1Route({
method: 'get', path: '/events/:id/attendance/export', tag: 'Attendance', summary: 'The register as a file',
description: '`format=pdf` or `xlsx`.', access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, query: filters('format', 'sessionId'),
file: { contentType: 'application/pdf' }, map: asEvent, v1: att.exportRegister,
});
v1Route({
method: 'post', path: '/events/:id/attendance', tag: 'Attendance', summary: 'Mark attendance',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: loose, map: asEvent, documented: { body: D.obj({ ticketId: D.str, sessionId: D.str.describe('A session id, or `event` on a single-date event'), present: D.bool }), response: D.ok }, v1: att.markAttendance,
});
v1Route({
method: 'post', path: '/events/:id/certificates', tag: 'Attendance', summary: 'Issue certificates',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: loose, map: asEvent, documented: { body: D.obj({ ticketId: D.optStr.describe('Just this person (default: everyone who qualifies)') }), response: D.obj({ issued: D.num }) }, v1: att.issueCertificates,
});
v1Route({
method: 'post', path: '/events/:id/certificates/send', tag: 'Attendance', summary: 'Send certificates',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: loose, map: asEvent, documented: { body: D.obj({ certificateId: D.optStr.describe('Just this one (default: every one not sent yet)') }), response: D.obj({ sent: D.num, skipped: D.num }) }, v1: att.sendCertificatesHandler,
});
v1Route({
method: 'get', path: '/events/:id/certificates.pdf', tag: 'Attendance', summary: 'Every certificate for an event, as one PDF',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, file: { contentType: 'application/pdf' }, map: asEvent, v1: att.downloadEventCertificates,
});
// ─── Allocations (groups, rooms, teams) ──────────────────────────────────────
v1Route({
method: 'get', path: '/me/allocation-groups', tag: 'Allocations', summary: 'Groups I lead',
access: 'staff', campusScope: 'campusIndependent (only the groups the signed-in person leads)', middleware: [cs.campusIndependent('only the groups the signed-in person leads')], documented: { response: D.obj({ medicalShown: D.bool, groups: z.array(D.obj({ id: D.str, name: D.str, capacity: D.optNum, gender: D.optStr, setName: D.str, event: D.eventRef.partial(), members: z.array(member) })) }) }, v1: alloc.getMyGroups,
});
v1Route({
method: 'get', path: '/events/:id/allocations', tag: 'Allocations', summary: 'An event\'s allocation board',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: D.obj({ event: D.anyObj, sets: z.array(allocationSet.extend({ groups: z.array(group), assignments: z.array(D.obj({ attendeeId: D.str, groupId: D.str })) })), attendees: z.array(D.obj({ id: D.str, name: D.str, gender: D.optStr, dateOfBirth: D.optDate, registrationId: D.str, bookedBy: D.str })), leaders: z.array(D.userRef), unnamedPlaces: D.num.describe('Places with no name yet') }) }, v1: alloc.getBoard,
});
v1Route({
method: 'post', path: '/allocation-sets', tag: 'Allocations', summary: 'Start a set of groups',
access: 'supervisor', campusScope: 'scopeEvent(body.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.body?.eventId })], status: 201, body: z.looseObject({ eventId: id }), documented: { body: D.obj({ eventId: D.str, name: D.str.describe('e.g. Cabins, Buses'), showOnTicket: D.optBool }), response: allocationSet }, v1: alloc.createSet,
});
const setScope = cs.scopeEvent({ resolve: 'allocationSet' });
const groupScope = cs.scopeEvent({ resolve: 'allocationGroup' });
v1Route({ method: 'patch', path: '/allocation-sets/:id', tag: 'Allocations', summary: 'Change a set', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, body: loose, documented: { body: D.obj({ name: D.optStr, showOnTicket: D.optBool }), response: allocationSet }, v1: alloc.updateSet });
v1Route({ method: 'delete', path: '/allocation-sets/:id', tag: 'Allocations', summary: 'Delete a set', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, documented: { response: D.message }, v1: alloc.deleteSet });
v1Route({ method: 'get', path: '/allocation-sets/:id/lists', tag: 'Allocations', summary: 'A set\'s lists (who is in each group)', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, query: filters('medical'), documented: { response: D.obj({ set: D.obj({ id: D.str, name: D.str }), event: D.anyObj, groups: z.array(D.obj({ id: D.str, name: D.str, capacity: D.optNum, gender: D.optStr, leader: D.optStr, members: z.array(member) })) }) }, v1: alloc.getSetLists });
v1Route({ method: 'post', path: '/allocation-sets/:id/groups', tag: 'Allocations', summary: 'Add a group', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], status: 201, params: idParams, body: loose, documented: { body: groupInput, response: group }, v1: alloc.createGroup });
v1Route({ method: 'put', path: '/allocation-sets/:id/assignments', tag: 'Allocations', summary: 'Put people in groups', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, body: loose, documented: { body: D.obj({ attendeeId: D.str, groupId: D.optStr.describe('null takes them out') }), response: D.message }, v1: alloc.assign });
v1Route({ method: 'delete', path: '/allocation-sets/:id/assignments', tag: 'Allocations', summary: 'Empty every group', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, documented: { response: D.message }, v1: alloc.clearAssignments });
v1Route({ method: 'post', path: '/allocation-sets/:id/auto', tag: 'Allocations', summary: 'Fill the groups automatically', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, body: loose, documented: { body: D.obj({ mode: z.enum(['together', 'split']).optional().describe('Keep each booking together, or spread them') }), response: D.obj({ placed: D.num, unplaced: D.num, message: D.str }) }, v1: alloc.autoAssign });
v1Route({ method: 'patch', path: '/allocation-groups/:id', tag: 'Allocations', summary: 'Change a group', access: 'supervisor', campusScope: 'scopeEvent(allocationGroup)', middleware: [groupScope], params: idParams, body: loose, documented: { body: groupInput, response: group }, v1: alloc.updateGroup });
v1Route({ method: 'delete', path: '/allocation-groups/:id', tag: 'Allocations', summary: 'Delete a group', access: 'supervisor', campusScope: 'scopeEvent(allocationGroup)', middleware: [groupScope], params: idParams, documented: { response: D.message }, v1: alloc.deleteGroup });
+114
View File
@@ -0,0 +1,114 @@
/**
* Corrections (1.52.0): see services/corrections.ts. Admins only, except "Fill in later" for
* staff at the door and in Manual registrations.
*/
import { z } from 'zod';
import type { Request } from 'express';
import { defineRoute, type ApiUser } from '../route';
import { single } from '../paging';
import { id, idParams } from '../schemas';
import * as corrections from '../../services/corrections';
/* eslint-disable @typescript-eslint/no-explicit-any -- campus scope middleware is JavaScript */
const campusScope = require('../../utils/campusScope') as {
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string }): any;
};
const audit = (req: Request, user: ApiUser | null) => {
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
return { actor: { id: user!.id, role: user!.role }, ip: getClientIp(req) };
};
const reason = z.string().trim().max(500).nullish().describe('Why — kept in the audit log');
const send = z.boolean().optional().describe('Send the new tickets to the attendee (off by default)');
const ticketRow = z.object({
id,
qrCode: z.string(),
label: z.string().describe('Ticket option, variant and (a picked session) the session'),
isMainTicket: z.boolean(),
name: z.string().describe('Who it\'s for'),
quantity: z.number().int(),
sessionIds: z.array(z.string()).describe(`The sessions it covers (\`${corrections.WHOLE_EVENT}\` on a single-date event)`),
checkedIn: z.record(z.string(), z.number().int()).describe('People checked in, per session'),
});
const registrationRow = z.object({
id,
status: z.string(),
createdAt: z.coerce.date(),
user: z.object({ id, name: z.string(), email: z.string().nullable(), phoneNumber: z.string().nullable() }),
totalDue: z.number(),
paid: z.number(),
balance: z.number(),
places: z.number().int().describe('Places booked (every line)'),
ticketPlaces: z.number().int().describe('Places with a ticket'),
missingTickets: z.boolean().describe('Paid (or released unpaid) but not every place has a ticket'),
formsNeeded: z.number().int().describe('Attendee forms still holding the tickets back'),
formWaived: z.boolean().describe('The form was left for later'),
ticketsReleased: z.boolean().describe('Tickets issued before it was paid'),
complimentary: z.number().describe('Balance written off'),
tickets: z.array(ticketRow),
});
export const eventCorrections = defineRoute({
method: 'get', path: '/corrections/events/:id', tag: 'Corrections', summary: 'An event\'s registrations, with what needs putting right',
description: 'Every registration that isn\'t cancelled — closed and past events too — with its balance, forms still to fill in, tickets and check-ins.',
access: 'admin', params: idParams,
response: single(z.object({
event: z.object({
id, title: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), closed: z.boolean(),
formRequired: z.boolean(), sessions: z.array(z.object({ id, label: z.string(), startsAt: z.coerce.date() })),
}),
registrations: z.array(registrationRow),
})),
async handler({ params }) { return { data: await corrections.eventCorrections(params.id) }; },
});
export const issueMissing = defineRoute({
method: 'post', path: '/corrections/events/:id/issue-missing', tag: 'Corrections', summary: 'Issue every missing ticket at an event',
description: 'For each paid registration (or one released unpaid) without all its tickets. Attendee forms still outstanding are left for later.',
access: 'admin', params: idParams, body: z.object({ send }),
response: single(z.object({ registrations: z.number().int(), tickets: z.number().int(), failed: z.array(z.object({ id, name: z.string(), message: z.string() })) })),
async handler({ params, body, user, req }) { return { data: await corrections.issueMissingTickets(params.id, audit(req, user), body) }; },
});
export const issueTickets = defineRoute({
method: 'post', path: '/corrections/registrations/:id/tickets', tag: 'Corrections', summary: 'Issue a registration\'s tickets now',
description: '`skipForm` leaves the attendee form for later; `payLater` issues them before it\'s paid (the balance is still owed).',
access: 'admin', params: idParams,
body: z.object({ skipForm: z.boolean().optional(), payLater: z.boolean().optional(), send, reason }),
response: single(z.object({ ticketsIssued: z.number().int(), registration: registrationRow })),
async handler({ params, body, user, req }) { return { data: await corrections.issueTickets(params.id, audit(req, user), body) }; },
});
export const complimentary = defineRoute({
method: 'post', path: '/corrections/registrations/:id/complimentary', tag: 'Corrections', summary: 'Make it free',
description: 'What\'s still owed is written off as a complimentary discount (income stays what was really paid), it\'s marked paid and its tickets are issued. Not on a closed event.',
access: 'admin', params: idParams,
body: z.object({ skipForm: z.boolean().optional(), send, reason }),
response: single(z.object({ amount: z.number(), ticketsIssued: z.number().int(), registration: registrationRow })),
async handler({ params, body, user, req }) { return { data: await corrections.makeComplimentary(params.id, audit(req, user), body) }; },
});
export const checkIn = defineRoute({
method: 'post', path: '/corrections/tickets/:id/check-in', tag: 'Corrections', summary: 'Check a ticket in (or undo it) after the fact',
description: 'Any time — after the event or session, or once the event is closed. `present: false` undoes the check-in for that session. Recorded as a manual check-in; no message is sent.',
access: 'admin', params: idParams,
body: z.object({
sessionId: id.nullish().describe('Multi-day events: the session'),
people: z.number().int().min(1).max(1000).nullish().describe('How many (default: everyone left on the ticket)'),
present: z.boolean().optional().describe('false undoes it'),
reason,
}),
response: single(z.object({ checkedIn: z.number().int(), quantity: z.number().int() })),
async handler({ params, body, user, req }) { return { data: await corrections.correctCheckIn(params.id, audit(req, user), body) }; },
});
export const formLater = defineRoute({
method: 'post', path: '/registrations/:id/form-later', tag: 'Registrations', summary: 'Fill in the attendee form later',
description: 'At the door: the tickets stop waiting for the required attendee form (issued now if it\'s paid) and the payer is sent a link to fill it in (`notify: false` to not).',
access: 'staff', campusScope: 'scopeEvent(registration)', middleware: [campusScope.scopeEvent({ resolve: 'registration' })], params: idParams,
body: z.object({ notify: z.boolean().optional() }),
response: single(z.object({ ticketsIssued: z.number().int(), sentTo: z.array(z.string()) })),
async handler({ params, body, user, req }) { return { data: await corrections.leaveFormForLater(params.id, audit(req, user), body) }; },
});
+2
View File
@@ -1,4 +1,5 @@
import { z } from 'zod';
import * as D from '../docs';
import type { Request } from 'express';
import { defineRoute, type ApiUser } from '../route';
import { single } from '../paging';
@@ -148,6 +149,7 @@ export const copyFrom = defineRoute({
middleware: [campusScope.scopeEvent({ need: 'manage' }), campusScope.scopeBody([['fromEventId', 'event']])],
params: idParams,
body: z.object({ fromEventId: id }),
documented: { response: D.obj({ sets: D.num.describe('Allocation sets copied'), groups: D.num.describe('Groups copied'), costs: D.num.describe('Costs copied (admins)') }) },
response: single(z.looseObject({})),
async handler({ params, body, user }) {
return { data: JSON.parse(JSON.stringify(await copySetupFrom(params.id, body.fromEventId, actorOf(user)) ?? {})) };
+141
View File
@@ -0,0 +1,141 @@
/**
* Kids check-in and volunteer rosters on v2 (1.51.13). Each route runs the v1 handler
* (viaV1.ts) behind the same access and campus checks as v1.
*/
import { z } from 'zod';
import { id, idParams } from '../schemas';
import { v1Route } from '../viaV1';
import * as D from '../docs';
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
const cs = require('../../utils/campusScope') as {
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown }): any;
scopeList: any;
};
const kids = require('../../controllers/kidsController');
const vol = require('../../controllers/volunteerController');
const loose = z.looseObject({});
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
/** v2 `:id` is the event; v1 called it `:eventId` (other params keep their names). */
const asEvent = (ctx: any) => ({ params: { ...ctx.req.params, eventId: ctx.params.id } });
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
const view = { campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()] };
const manage = { campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })] };
// ─── Documented shapes (docs.ts) ─────────────────────────────────────────────
const child = D.obj({
id: D.str, firstName: D.str, lastName: D.str, name: D.str, dateOfBirth: D.optDate, age: D.optNum, gender: D.optStr,
allergies: D.optStr, hasMedical: D.bool, hasCustody: D.bool.describe('There are custody notes'),
medicalNotes: D.optStr.describe('Only where notes may be shown'), custodyNotes: D.optStr, lastCheckInAt: D.optDate,
});
const childInput = D.obj({
firstName: D.str, lastName: D.optStr, dateOfBirth: D.optDate, gender: D.optStr.describe('male or female (anything else: not given)'),
allergies: D.optStr, medicalNotes: D.optStr, custodyNotes: D.optStr.describe('Kept encrypted, like the medical notes'),
});
const adultInput = D.obj({ name: D.str, phone: D.optStr, relationship: D.optStr });
const adult = adultInput.extend({ id: D.str });
const family = D.obj({
id: D.str, name: D.str,
parents: z.array(D.obj({ userId: D.str, name: D.str, phone: D.optStr, email: D.optStr })),
children: z.array(child), adults: z.array(adult).describe('Others who may collect the children'),
});
const myFamily = D.obj({
family: family.nullable(), qrToken: D.optStr.describe('The family QR code (shown at the desk)'),
codes: z.array(D.anyObj).describe('Collection codes for children checked in now'), pages: z.array(D.anyObj).describe('Recent "come to the room" messages'),
});
const parentInput = D.obj({ name: D.str, email: D.optStr, phone: D.optStr.describe('Email or phone needed: the collection code is sent there') });
const room = D.obj({ id: D.str, name: D.str, minAge: D.optNum, maxAge: D.optNum, capacity: D.optNum, ratio: D.optNum, order: D.optNum, leaderNames: z.array(D.str).optional(), leaderIds: D.ids.optional() });
const board = D.obj({
event: D.anyObj, occasion: D.str.describe('The day or session it\'s for'), sessionId: D.optStr, sessionLabel: D.optStr,
rooms: z.array(room), checkIns: z.array(D.anyObj).describe('Children checked in, with room and code'),
});
const roleShift = D.obj({ startsAt: D.date, endsAt: D.date, text: D.str, hours: D.num });
const role = D.obj({
id: D.str, eventId: D.str, sessionId: D.optStr, sessionLabel: D.optStr, name: D.str, description: D.str,
startsAt: D.optDate, endsAt: D.optDate, shift: roleShift, kidsRoom: D.anyObj.nullish(), order: D.num, past: D.bool,
needed: D.optNum, filled: D.optNum,
mine: D.obj({ slotId: D.str, checkedInAt: D.optDate, swapId: D.optStr }).nullable().describe('My place in it'),
coverNeeded: z.array(D.obj({ swapId: D.str, note: D.str })),
volunteers: z.array(D.obj({ slotId: D.str, userId: D.str, name: D.str, phone: D.optStr, email: D.optStr, assignedBy: D.optStr, checkedInAt: D.optDate, checkedInBy: D.optStr, checkInMethod: D.optStr, swapOpen: D.bool, hasTicket: D.bool })).optional().describe('Staff only'),
});
const roster = D.obj({
event: D.anyObj, sessions: z.array(D.obj({ id: D.str, label: D.str, startsAt: D.date, endsAt: D.date })), roles: z.array(role),
totals: D.obj({ needed: D.optNum, filled: D.optNum, fillRate: D.optNum.describe('%') }),
});
const roleInput = D.obj({
name: D.str, description: D.optStr, sessionId: D.optStr.describe('A multi-day event: the session'), startsAt: D.optDate, endsAt: D.optDate.describe('Default: the event\'s or session\'s times'),
needed: D.numeric.nullish().describe('How many people'), kidsRoomId: D.optStr.describe('Serving in a kids room: counts as a leader on duty'), order: D.numeric.optional(),
});
const roleRecord = D.obj({ id: D.str, eventId: D.str, name: D.str, description: D.optStr, sessionId: D.optStr, startsAt: D.optDate, endsAt: D.optDate, needed: D.optNum, kidsRoomId: D.optStr, order: D.num });
const signedUp = D.obj({ slotId: D.str, ticket: D.bool.describe('A free volunteer ticket was issued') });
// ─── Kids: my family ─────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/me/family', tag: 'Kids', summary: 'My family', description: 'Children, the adults who may collect them, and the family QR code.', access: 'user', documented: { response: myFamily }, v1: kids.getMine });
v1Route({ method: 'post', path: '/me/family', tag: 'Kids', summary: 'Start my family', access: 'user', status: 201, body: loose, documented: { body: D.obj({ name: D.optStr }), response: myFamily }, v1: kids.createMine });
v1Route({ method: 'patch', path: '/me/family', tag: 'Kids', summary: 'Rename my family', access: 'user', body: loose, documented: { body: D.obj({ name: D.str }), response: family }, v1: kids.renameMine });
v1Route({ method: 'delete', path: '/me/family', tag: 'Kids', summary: 'Leave my family', access: 'user', documented: { response: D.anyObj.describe('What happened: left, or the family was deleted as the last parent') }, v1: kids.leaveMine });
v1Route({ method: 'post', path: '/me/family/children', tag: 'Kids', summary: 'Add a child', access: 'user', status: 201, body: loose, documented: { body: childInput, response: child }, v1: kids.saveMyChild });
v1Route({ method: 'put', path: '/me/family/children/:id', tag: 'Kids', summary: 'Change a child', access: 'user', params: idParams, body: loose, documented: { body: childInput.partial(), response: child }, v1: kids.saveMyChild });
v1Route({ method: 'delete', path: '/me/family/children/:id', tag: 'Kids', summary: 'Remove a child', access: 'user', params: idParams, documented: { response: D.message }, v1: kids.deleteMyChild });
v1Route({ method: 'post', path: '/me/family/adults', tag: 'Kids', summary: 'Add an adult who may collect', access: 'user', status: 201, body: loose, documented: { body: adultInput, response: adult }, v1: kids.saveMyAdult });
v1Route({ method: 'put', path: '/me/family/adults/:id', tag: 'Kids', summary: 'Change an adult', access: 'user', params: idParams, body: loose, documented: { body: adultInput, response: adult }, v1: kids.saveMyAdult });
v1Route({ method: 'delete', path: '/me/family/adults/:id', tag: 'Kids', summary: 'Remove an adult', access: 'user', params: idParams, documented: { response: D.message }, v1: kids.deleteMyAdult });
// ─── Kids: set-up and the check-in desk ──────────────────────────────────────
v1Route({ method: 'get', path: '/kids/setup-events', tag: 'Kids', summary: 'Events that can have kids check-in', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], documented: { response: z.array(D.event.partial().extend({ kidsCheckIn: D.optBool })) }, v1: kids.setupEvents });
v1Route({ method: 'get', path: '/kids/events', tag: 'Kids', summary: 'Events with kids check-in', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], documented: { response: z.array(D.event.partial()) }, v1: kids.listEvents });
v1Route({ method: 'get', path: '/events/:id/kids/settings', tag: 'Kids', summary: 'An event\'s kids check-in set-up', access: 'supervisor', ...view, params: idParams, map: asEvent, documented: { response: D.obj({ event: D.obj({ id: D.str, title: D.str, scheduleType: D.optStr, kidsCheckIn: D.bool, kidsOptionId: D.optStr, kidsRoomSetId: D.optStr }), options: z.array(D.eventOption.partial()), sets: z.array(D.anyObj).describe('Allocation sets that can be the rooms') }) }, v1: kids.getSettings });
v1Route({ method: 'put', path: '/events/:id/kids/settings', tag: 'Kids', summary: 'Set up kids check-in', access: 'supervisor', ...manage, params: idParams, body: loose, map: asEvent, documented: { body: D.obj({ enabled: D.optBool, optionId: D.optStr.describe('The kids ticket (`new` makes a free one; null: none)'), roomSetId: D.optStr.describe('The allocation set used as rooms') }).describe('Only what changes'), response: D.anyObj.describe('The saved set-up, as GET answers') }, v1: kids.saveSettings });
v1Route({ method: 'get', path: '/events/:id/kids/board', tag: 'Kids', summary: 'Who is checked in, by room', access: 'staff', ...view, params: idParams, query: filters('sessionId', 'log'), map: asEvent, documented: { response: board }, v1: kids.getBoard });
v1Route({ method: 'get', path: '/events/:id/kids/families', tag: 'Kids', summary: 'Find a family', access: 'staff', ...view, params: idParams, query: filters('q', 'sessionId'), map: asEvent, documented: { response: z.array(family) }, v1: kids.searchFamilies });
v1Route({ method: 'post', path: '/events/:id/kids/families', tag: 'Kids', summary: 'Register a family at the desk', access: 'staff', ...view, status: 201, params: idParams, body: loose, map: asEvent, documented: { body: D.obj({ name: D.optStr, parent: parentInput, children: z.array(childInput).max(12).optional() }), response: family }, v1: kids.createFamilyAtDesk });
v1Route({ method: 'get', path: '/events/:id/kids/families/qr/:token', tag: 'Kids', summary: 'The family for a QR code', access: 'staff', ...view, params: z.object({ id, token: z.string().min(1).max(200) }), map: asEvent, documented: { response: family }, v1: kids.familyByQr });
const familyParams = z.object({ id, familyId: id });
v1Route({ method: 'get', path: '/events/:id/kids/families/:familyId', tag: 'Kids', summary: 'One family', access: 'staff', ...view, params: familyParams, map: asEvent, documented: { response: family }, v1: kids.getFamily });
v1Route({ method: 'post', path: '/events/:id/kids/families/:familyId/parents', tag: 'Kids', summary: 'Add a parent at the desk', access: 'staff', ...view, status: 201, params: familyParams, body: loose, map: asEvent, documented: { body: parentInput, response: family }, v1: kids.addParentAtDesk });
v1Route({ method: 'post', path: '/events/:id/kids/families/:familyId/children', tag: 'Kids', summary: 'Add a child at the desk', access: 'staff', ...view, status: 201, params: familyParams, body: loose, map: asEvent, documented: { body: childInput, response: child }, v1: kids.saveChildAtDesk });
v1Route({ method: 'put', path: '/events/:id/kids/families/:familyId/children/:childId', tag: 'Kids', summary: 'Change a child at the desk', access: 'staff', ...view, params: z.object({ id, familyId: id, childId: id }), body: loose, map: asEvent, documented: { body: childInput.partial(), response: child }, v1: kids.saveChildAtDesk });
v1Route({ method: 'post', path: '/events/:id/kids/families/:familyId/adults', tag: 'Kids', summary: 'Add an adult at the desk', access: 'staff', ...view, status: 201, params: familyParams, body: loose, map: asEvent, documented: { body: adultInput, response: family }, v1: kids.addAdultAtDesk });
v1Route({ method: 'post', path: '/events/:id/kids/check-in', tag: 'Kids', summary: 'Check children in', description: 'Answers the collection code and labels to print.', access: 'staff', ...view, params: idParams, body: loose, map: asEvent, documented: { body: D.obj({ familyId: D.str, sessionId: D.optStr, children: z.array(D.obj({ childId: D.str, roomId: D.optStr })).describe('Who, and into which room (default: by age)'), sendToAdultId: D.optStr.describe('Send the code to this adult too') }), response: D.obj({ code: D.str.describe('The collection code'), qr: D.str, occasion: D.str, family: D.obj({ id: D.str, name: D.str }), checkIns: z.array(D.obj({ id: D.str, name: D.str, firstName: D.str, room: D.anyObj.nullish(), already: D.bool, sticker: D.optStr.describe('Write this on the sticker') })) }) }, v1: kids.checkIn });
v1Route({ method: 'post', path: '/events/:id/kids/check-out', tag: 'Kids', summary: 'Check children out', description: 'With the collection code; refused for someone not allowed to collect.', access: 'staff', ...view, params: idParams, body: loose, map: asEvent, documented: { body: D.obj({ code: D.str, checkInIds: D.ids.optional().describe('Just these children (default: all on the code)'), collectedBy: D.optStr.describe('Who collected them'), roomId: D.optStr, sessionId: D.optStr }), response: D.obj({ checkedOut: z.array(D.obj({ id: D.str, name: D.str, room: D.optStr })) }) }, v1: kids.checkOut });
v1Route({ method: 'get', path: '/events/:id/kids/code/:code', tag: 'Kids', summary: 'Who a collection code is for', access: 'staff', ...view, params: z.object({ id, code: z.string().min(1).max(40) }), query: filters('sessionId'), map: asEvent, documented: { response: D.obj({ code: D.str, found: D.bool, family: D.anyObj.nullish(), children: z.array(D.obj({ id: D.str, name: D.str, room: D.anyObj.nullish(), checkedOutAt: D.optDate, collectedBy: D.optStr })) }) }, v1: kids.lookupCode });
v1Route({ method: 'post', path: '/events/:id/kids/duty', tag: 'Kids', summary: 'Say which room I\'m on', access: 'staff', ...view, params: idParams, body: loose, map: asEvent, documented: { body: D.obj({ roomId: D.str, sessionId: D.optStr, on: D.optBool.describe('false: off duty') }), response: D.obj({ roomId: D.str, on: D.bool }) }, v1: kids.setDuty });
v1Route({ method: 'get', path: '/events/:id/kids/offline', tag: 'Kids', summary: 'Everything the desk needs offline', access: 'staff', ...view, params: idParams, query: filters('deviceId', 'sessionId'), map: asEvent, documented: { response: board.extend({ families: z.array(family), codes: z.array(D.anyObj).describe('Codes reserved for this device'), downloadedAt: D.date }) }, v1: kids.offlineSnapshot });
v1Route({ method: 'post', path: '/events/:id/kids/sync', tag: 'Kids', summary: 'Upload check-ins made offline', access: 'staff', ...view, params: idParams, body: loose, map: asEvent, documented: { body: D.obj({ deviceId: D.str, sessionId: D.optStr, checkIns: z.array(D.anyObj).optional(), checkOuts: z.array(D.anyObj).optional() }), response: D.obj({ checkedIn: D.num, checkedOut: D.num, conflicts: z.array(D.anyObj) }) }, v1: kids.syncOffline });
const checkInScope = { campusScope: 'scopeEvent(kidsCheckIn)', middleware: [cs.scopeEvent({ resolve: 'kidsCheckIn' })] };
v1Route({ method: 'post', path: '/kids/check-ins/:id/override', tag: 'Kids', summary: 'Let someone not on the list collect', description: 'Logged, with the reason.', access: 'staff', ...checkInScope, params: idParams, body: loose, documented: { body: D.obj({ reason: D.str, collectedBy: D.str }), response: D.obj({ id: D.str, checkedOutAt: D.date }) }, v1: kids.overrideCheckOut });
v1Route({ method: 'post', path: '/kids/check-ins/:id/page', tag: 'Kids', summary: 'Page a child\'s parent', access: 'staff', ...checkInScope, params: idParams, body: loose, documented: { body: D.obj({ message: D.optStr }), response: D.obj({ sent: z.array(D.str).describe('Channels used'), text: D.str }) }, v1: kids.pageParent });
v1Route({ method: 'put', path: '/kids/check-ins/:id/room', tag: 'Kids', summary: 'Move a child to another room', access: 'staff', ...checkInScope, params: idParams, body: loose, documented: { body: D.obj({ roomId: D.str }), response: D.obj({ id: D.str, roomId: D.str }) }, v1: kids.moveRoom });
v1Route({ method: 'post', path: '/kids/alerts/:id/resolve', tag: 'Kids', summary: 'Resolve an alert', access: 'staff', campusScope: 'scopeEvent(kidsAlert)', middleware: [cs.scopeEvent({ resolve: 'kidsAlert' })], params: idParams, body: loose, documented: { response: D.obj({ resolved: D.num }) }, v1: kids.resolveAlert });
// ─── Volunteering: mine ──────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/me/volunteering', tag: 'Volunteers', summary: 'Where I\'m serving', access: 'user', documented: { response: D.obj({ slots: z.array(D.obj({ slotId: D.str, role: D.anyObj, event: D.anyObj, shift: roleShift, checkedInAt: D.optDate, canCheckIn: D.bool, past: D.bool, swapId: D.optStr, hasTicket: D.bool })), cover: z.array(D.anyObj).describe('Slots others want covered'), events: z.array(D.obj({ id: D.str, title: D.str, startDate: D.date, endDate: D.date, location: D.optStr, roles: D.num })) }) }, v1: vol.getMine });
v1Route({ method: 'get', path: '/events/:id/volunteer-roles', tag: 'Volunteers', summary: 'Roles I can serve in at an event', access: 'user', params: idParams, map: asEvent, documented: { response: roster }, v1: vol.getEventRoles });
v1Route({ method: 'post', path: '/volunteer-roles/:id/sign-up', tag: 'Volunteers', summary: 'Sign up to serve', access: 'user', status: 201, params: idParams, body: loose, documented: { response: signedUp }, v1: vol.signUp });
v1Route({ method: 'delete', path: '/volunteer-slots/:id', tag: 'Volunteers', summary: 'Stop serving in a slot', access: 'user', params: idParams, documented: { response: D.message }, v1: vol.withdraw });
v1Route({ method: 'post', path: '/volunteer-slots/:id/swap', tag: 'Volunteers', summary: 'Ask someone to take my slot', access: 'user', params: idParams, body: loose, documented: { body: D.obj({ note: D.optStr }), response: D.obj({ swapId: D.str }) }, v1: vol.requestSwap });
v1Route({ method: 'delete', path: '/volunteer-swaps/:id', tag: 'Volunteers', summary: 'Cancel a swap request', access: 'user', params: idParams, documented: { response: D.obj({ cancelled: z.literal(true) }) }, v1: vol.cancelSwap });
v1Route({ method: 'post', path: '/volunteer-swaps/:id/accept', tag: 'Volunteers', summary: 'Take someone\'s slot', access: 'user', params: idParams, body: loose, documented: { response: D.obj({ slotId: D.str, role: D.str, event: D.str }) }, v1: vol.acceptSwap });
v1Route({ method: 'post', path: '/volunteering/check-in', tag: 'Volunteers', summary: 'Check myself in (the event\'s QR code)', access: 'user', body: loose, documented: { body: D.obj({ qr: D.str.describe('What the QR code holds') }), response: D.obj({ event: D.obj({ id: D.str, title: D.str }), checkedIn: z.array(D.obj({ role: D.str, kidsRoom: D.optStr })), already: z.array(D.str) }) }, v1: vol.checkIn });
// ─── Volunteering: rosters ───────────────────────────────────────────────────
v1Route({ method: 'get', path: '/volunteering/events', tag: 'Volunteers', summary: 'Events with volunteer roles', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('all'), documented: { response: z.array(D.obj({ id: D.str, title: D.str, startDate: D.date, endDate: D.date, scheduleType: D.optStr, campusId: D.optStr, roles: D.num })) }, v1: vol.listEvents });
v1Route({ method: 'get', path: '/volunteering/hours', tag: 'Volunteers', summary: 'Hours served', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('from', 'to', 'eventId'), documented: { response: z.array(D.obj({ userId: D.str.optional(), name: D.str, hours: D.num, shifts: D.optNum })) }, v1: vol.hours });
v1Route({ method: 'get', path: '/events/:id/volunteers', tag: 'Volunteers', summary: 'An event\'s roster', access: 'staff', ...view, params: idParams, query: filters('log'), map: asEvent, documented: { response: roster }, v1: vol.getRoster });
v1Route({ method: 'get', path: '/events/:id/volunteers/qr', tag: 'Volunteers', summary: 'The self check-in QR code', access: 'staff', ...view, params: idParams, map: asEvent, documented: { response: D.obj({ token: D.str, qr: D.str.describe('Put this in the QR code') }) }, v1: vol.getQr });
v1Route({ method: 'get', path: '/events/:id/volunteers/settings', tag: 'Volunteers', summary: 'Volunteer set-up', access: 'supervisor', ...view, params: idParams, map: asEvent, documented: { response: D.obj({ event: D.obj({ id: D.str, title: D.str, volunteerOptionId: D.optStr }), options: z.array(D.eventOption.partial()), kidsRooms: z.array(D.anyObj) }) }, v1: vol.getSettings });
v1Route({ method: 'put', path: '/events/:id/volunteers/settings', tag: 'Volunteers', summary: 'Set up volunteering', access: 'supervisor', ...manage, params: idParams, body: loose, map: asEvent, documented: { body: D.obj({ optionId: D.optStr.describe('The free volunteer ticket (null: none)') }), response: D.anyObj.describe('The saved set-up, as GET answers') }, v1: vol.saveSettings });
v1Route({ method: 'post', path: '/events/:id/volunteer-roles', tag: 'Volunteers', summary: 'Add a role', access: 'supervisor', ...manage, status: 201, params: idParams, body: loose, map: asEvent, documented: { body: roleInput, response: roleRecord }, v1: vol.createRole });
const roleManage = { campusScope: 'scopeEvent(servingRole, manage)', middleware: [cs.scopeEvent({ resolve: 'servingRole', need: 'manage' })] };
v1Route({ method: 'patch', path: '/volunteer-roles/:id', tag: 'Volunteers', summary: 'Change a role', access: 'supervisor', ...roleManage, params: idParams, body: loose, documented: { body: roleInput.partial(), response: roleRecord }, v1: vol.updateRole });
v1Route({ method: 'delete', path: '/volunteer-roles/:id', tag: 'Volunteers', summary: 'Delete a role', access: 'supervisor', ...roleManage, params: idParams, documented: { response: D.obj({ message: D.str, volunteers: D.num.describe('People who were in it (told by message)') }) }, v1: vol.deleteRole });
v1Route({ method: 'post', path: '/volunteer-roles/:id/assign', tag: 'Volunteers', summary: 'Put someone in a role', access: 'supervisor', campusScope: 'scopeEvent(servingRole)', middleware: [cs.scopeEvent({ resolve: 'servingRole' })], params: idParams, body: loose, documented: { body: D.obj({ userId: D.str }), response: signedUp }, v1: vol.assign });
const slotScope = { campusScope: 'scopeEvent(volunteerSlot)', middleware: [cs.scopeEvent({ resolve: 'volunteerSlot' })] };
v1Route({ method: 'post', path: '/volunteer-slots/:id/check-in', tag: 'Volunteers', summary: 'Check a volunteer in', access: 'staff', ...slotScope, params: idParams, body: loose, documented: { body: D.obj({ undo: D.optBool }), response: D.obj({ slotId: D.str, changed: D.bool }) }, v1: vol.checkInManual });
v1Route({ method: 'post', path: '/volunteer-slots/:id/remove', tag: 'Volunteers', summary: 'Take someone off a slot', access: 'supervisor', ...slotScope, params: idParams, body: loose, documented: { response: D.message }, v1: vol.remove });
+24 -4
View File
@@ -25,20 +25,40 @@ const toMe = (u: MeRow): z.input<typeof me> => ({
notificationPreference: u.notificationPreference, homeCampusId: u.homeCampusId ?? null, createdAt: iso(u.createdAt),
});
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
const { requiresTwoFactor } = require('../../utils/twoFactor') as { requiresTwoFactor(user: object): boolean };
const privacyNotice = require('../../utils/privacyNotice') as { noticePromptPending(u: { noticeVersionAccepted: unknown }): Promise<false | 'first' | 'updated'> };
const campusScope = require('../../utils/campusScope') as { scopeFor(req: unknown): Promise<{ campusIds: string[] | null } | null> };
export const getMe = defineRoute({
method: 'get',
path: '/me',
tag: 'Me',
summary: 'The signed-in person',
description: 'With what the app asks after sign-in (`newsPromptPending`, `noticePromptPending`), whether two-step sign-in is required for their role, and the campuses they\'re limited to (`campusScope`, null = every campus). Viewing as someone else never prompts.',
access: 'user',
response: single(me),
async handler({ user }) {
response: single(me.extend({
twoFactorRequired: z.boolean().describe('Their role must use two-step sign-in'),
newsPromptPending: z.boolean(),
noticePromptPending: z.union([z.literal(false), z.enum(['first', 'updated'])]),
campusScope: z.array(id).nullable(),
})),
async handler({ user, req }) {
const u = await prisma.user.findUnique({
where: { id: user!.id },
select: { id: true, name: true, email: true, phoneNumber: true, role: true, twoFactorEnabled: true, notificationPreference: true, homeCampusId: true, createdAt: true },
select: { id: true, name: true, email: true, phoneNumber: true, role: true, twoFactorEnabled: true, twoFactorMethod: true, notificationPreference: true, homeCampusId: true, createdAt: true, newsAskedAt: true, noticeVersionAccepted: true },
});
if (!u) throw new ApiError('UNAUTHENTICATED', 'User not found');
return { data: toMe(u) };
const impersonating = !!(req as any).impersonation;
return {
data: {
...toMe(u),
twoFactorRequired: requiresTwoFactor(u),
newsPromptPending: !u.newsAskedAt && !impersonating,
noticePromptPending: impersonating ? false as const : await privacyNotice.noticePromptPending({ noticeVersionAccepted: u.noticeVersionAccepted }),
campusScope: (await campusScope.scopeFor(req))?.campusIds ?? null,
},
};
},
});
+366
View File
@@ -0,0 +1,366 @@
/**
* Money on v2 (1.51.11): payments and refunds, payment links and providers, cash-ups, provider
* transactions, the waitlist, sponsorship, consent, attendee form answers and feedback. Each
* route runs the v1 handler (viaV1.ts) behind the same access and campus checks as v1.
*/
import { z } from 'zod';
import { id, idParams } from '../schemas';
import { listData, v1Route } from '../viaV1';
import * as D from '../docs';
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
const rateLimit = require('express-rate-limit') as (opts: object) => any;
const cs = require('../../utils/campusScope') as {
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown; ownerOk?: boolean }): any;
scopeBody(targets: [string, string][], opts?: { need?: 'view' | 'manage'; orphan?: 'pass' | 'deny' }): any;
scopeList: any;
unscopedOnly: any;
};
const pay = require('../../controllers/paymentController');
const providers = require('../../controllers/paymentProvidersController');
const cashup = require('../../controllers/cashupController');
const ptx = require('../../controllers/providerTransactionsController');
const waitlist = require('../../controllers/waitlistController');
const sponsor = require('../../controllers/sponsorshipController');
const consent = require('../../controllers/consentController');
const forms = require('../../controllers/formController');
const feedback = require('../../controllers/feedbackController');
/** Optional text filters, passed to v1 as they come. */
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
const paymentFilters = filters('page', 'limit', 'userId', 'from', 'donations', 'method', 'kind', 'startDate', 'endDate');
const tokenParams = z.object({ token: z.string().min(1).max(200) });
const asEvent = (ctx: any) => ({ params: { ...ctx.req.params, eventId: ctx.params.id } });
// Links in messages are credentials, so guessing stays slow (as on v1).
const linkLimiter = () => rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
// ─── Documented shapes (docs.ts) ─────────────────────────────────────────────
const providerRow = D.obj({ id: D.str.describe('yoco, payfast'), label: D.str });
const providerSettings = D.obj({
id: D.str, label: D.str, enabled: D.bool, configured: D.bool.describe('Its keys are set up'),
capabilities: D.anyObj.describe('What the provider can do, e.g. look payments up'),
});
const openCheckout = D.obj({
id: D.str, provider: D.str, providerLabel: D.str, canCheck: D.bool.describe('The provider can be asked whether it was paid'),
amount: D.rand, description: D.str, type: D.optStr.describe('registration, donation, sponsorship, season_pass…'),
registrationId: D.optStr, payer: D.optStr, createdAt: D.date,
});
const payPage = D.obj({
id: D.str, status: z.enum(['open', 'paid']), amount: D.rand, description: D.str,
provider: D.optStr.describe('The provider already chosen, if any'), providers: z.array(providerRow), cancelUrl: D.optStr,
redirectUrl: D.optStr, form: D.anyObj.nullish().describe('A signed form to post (PayFast)'),
});
const paymentFull = D.payment.extend({
registration: D.registration.nullish(),
generatedTickets: z.array(D.ticket).optional().describe('Tickets issued because this paid it in full'),
formsNeeded: D.optNum.describe('Attendee forms still holding the tickets back (1.52.0)'),
donationSplit: D.payment.optional().describe('What was paid over the balance, kept as a donation'),
});
const cashupLineInput = D.obj({
method: D.str.describe('cash, card, eft…'),
bankAmount: D.optRand, onHandAmount: D.optRand.describe('Cash only'), pendingAmount: D.optRand.describe('Not cash'), pendingReference: D.optStr,
writeOffs: z.array(D.obj({ category: D.str, amount: D.optRand, reason: D.optStr })).optional(), notes: D.optStr,
});
const cashupRecord = D.obj({ id: D.str, eventId: D.str, action: D.optStr.describe('close, quick_close, reopen'), notes: D.optStr, createdAt: D.date, performedBy: D.userRef.nullish() });
const providerTransaction = D.obj({
id: D.str, provider: D.str, providerLabel: D.str, externalId: D.str.describe('The provider\'s id'),
amount: D.optNum.describe('Cents'), currency: D.optStr, createdDate: D.optDate, checkoutId: D.optStr, methodType: D.optStr,
reconciled: D.bool, ignored: D.bool, paymentId: D.optStr, note: D.optStr.describe('Why it needs a look'), metadata: D.anyObj.nullish(),
});
const txAnswer = <T extends z.ZodType>(data: T) => D.obj({ success: D.bool, data, message: D.optStr });
const waitlistEntry = D.obj({
id: D.str, eventId: D.str, eventOptionId: D.str, variantId: D.optStr, sessionId: D.optStr, userId: D.str,
quantity: D.num, position: D.num.describe('Place in the queue'), status: D.str.describe('waiting, offered, claimed, expired, removed, left'),
offeredQuantity: D.optNum, offeredAt: D.optDate, offerExpiresAt: D.optDate, claimedAt: D.optDate, registrationId: D.optStr, createdAt: D.date,
});
const consentRow = D.obj({
name: D.str.describe('The young person'), status: D.str.describe('not_requested, pending, given, declined'),
parentName: D.optStr, parentEmail: D.optStr, parentPhone: D.optStr, request: D.anyObj.nullish(),
});
const feedbackField = D.obj({ id: D.str.optional(), type: D.str.describe('rating, multiple_choice, text, yes_no…'), label: D.str, isRequired: D.optBool, helpText: D.optStr, options: z.array(D.str).nullish() });
const feedbackForm = D.obj({
id: D.str, eventId: D.str, sendAt: D.optDate, sentAt: D.optDate, audience: D.str.describe('checked_in or everyone'), anonymous: D.bool, intro: D.optStr,
fields: z.array(feedbackField), invited: D.optNum, responses: z.unknown().optional().describe('Answers per question'),
}).nullable().describe('null when the event has no feedback form');
const UNSCOPED = 'unscopedOnly (admins and unscoped supervisors)';
// ─── Payments ────────────────────────────────────────────────────────────────
v1Route({
method: 'get', path: '/payments/providers', tag: 'Payments', summary: 'The payment providers switched on',
access: 'public', documented: { response: D.obj({ providers: z.array(providerRow), minimumAmount: D.rand.describe('Smallest online payment') }) }, v1: providers.listPaymentProviders,
});
v1Route({
method: 'get', path: '/payments/providers/settings', tag: 'Payments', summary: 'Payment provider settings',
description: 'Admins. Keys are masked.', access: 'admin', documented: { response: D.obj({ providers: z.array(providerSettings), minimumAmount: D.rand }) }, v1: providers.getPaymentProviderSettings,
});
v1Route({
method: 'put', path: '/payments/providers/settings', tag: 'Payments', summary: 'Change payment provider settings',
access: 'admin', body: z.looseObject({}), documented: { body: D.obj({ providers: z.array(D.obj({ id: D.str, enabled: D.bool })).min(1).describe('Every provider, in the order to offer them') }), response: D.obj({ message: D.str, providers: z.array(providerSettings) }) }, v1: providers.updatePaymentProviderSettings,
});
v1Route({
method: 'get', path: '/payments/open-checkouts', tag: 'Payments', summary: 'Checkouts started but not finished',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], documented: { response: D.obj({ checkouts: z.array(openCheckout), days: D.num.describe('How far back it looks') }) }, v1: providers.listOpenCheckouts,
});
v1Route({
method: 'post', path: '/payments/open-checkouts/check', tag: 'Payments', summary: 'Ask the provider about open checkouts',
description: 'Records any that were paid (for when a webhook was missed).',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], body: z.looseObject({}), documented: { body: D.obj({ ids: D.ids.optional().describe('Checkouts to ask about (default: every open one), at most 200') }), response: D.obj({ results: z.array(D.anyObj), recorded: D.num.describe('Payments found and recorded'), message: D.str }) }, v1: providers.checkCheckoutStatus,
});
v1Route({
method: 'get', path: '/payments/stats', tag: 'Payments', summary: 'Payment totals', access: 'admin', query: paymentFilters, documented: { response: D.obj({ totalToday: D.rand, totalWeek: D.rand, totalMonth: D.rand }) }, v1: pay.getPaymentStats,
});
v1Route({
method: 'get', path: '/payments', tag: 'Payments', summary: 'Every payment',
description: 'For the events you may see. `page` and `limit` page it (otherwise everything); see v1 for the filters it takes.',
access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: paymentFilters, documented: { response: D.v1Page(D.payment.extend({ registration: D.registration.nullish() })) }, v1: pay.getPayments,
});
v1Route({
method: 'post', path: '/payments', tag: 'Payments', summary: 'Record a payment',
description: 'Cash, card, EFT or other, against a registration or (a donation) an event. Paid in full issues and sends the tickets.',
access: 'supervisor', campusScope: 'scopeBody(registrationId, eventId; deny orphans)', status: 201,
middleware: [cs.scopeBody([['registrationId', 'registration'], ['eventId', 'event']], { orphan: 'deny' })],
body: z.looseObject({ registrationId: id.nullish(), eventId: id.nullish(), amount: z.union([z.number(), z.string()]), method: z.string().max(20).optional() }),
documented: { body: D.obj({ registrationId: D.optStr, eventId: D.optStr.describe('A donation: the event'), amount: D.numeric, method: D.str.describe('cash, card, eft or other'), isDonation: D.optBool, paidAt: D.optDate.describe('Backdate it'), userId: D.optStr.describe('A donation: who gave it') }), response: paymentFull }, v1: pay.createPayment,
});
v1Route({
method: 'post', path: '/payments/refunds', tag: 'Payments', summary: 'Refund a payment',
access: 'supervisor', campusScope: 'scopeBody(paymentId, registrationId; manage, deny orphans)', status: 201,
middleware: [cs.scopeBody([['paymentId', 'payment'], ['registrationId', 'registration']], { need: 'manage', orphan: 'deny' })],
body: z.looseObject({ paymentId: id.optional(), registrationId: id.optional() }), documented: { body: D.obj({ paymentId: D.optStr.describe('The payment refunded (or registrationId)'), registrationId: D.optStr, userId: D.str, amount: D.numeric.describe('Positive; stored as a negative payment'), method: D.str, reason: D.optStr }), response: D.payment }, v1: pay.createRefund,
});
v1Route({
method: 'post', path: '/payments/donations/assign', tag: 'Payments', summary: 'Put a donation towards a registration',
access: 'supervisor', campusScope: 'scopeBody(paymentId deny orphans; registrationId)',
middleware: [cs.scopeBody([['paymentId', 'payment']], { orphan: 'deny' }), cs.scopeBody([['registrationId', 'registration']])],
body: z.looseObject({ paymentId: id, registrationId: id }), documented: { body: D.obj({ paymentId: D.str.describe('The donation'), registrationId: D.str, amount: D.numeric.optional().describe('Default: as much as both allow') }), response: D.obj({ message: D.optStr, updatedRegistration: D.registration.optional(), generatedTickets: z.array(D.ticket).optional() }) }, v1: pay.assignDonationToRegistration,
});
v1Route({
method: 'post', path: '/payments/donations/unassign', tag: 'Payments', summary: 'Take a donation back off a registration',
access: 'supervisor', campusScope: 'scopeBody(legId; deny orphans)', middleware: [cs.scopeBody([['legId', 'payment']], { orphan: 'deny' })],
body: z.looseObject({ legId: id }), documented: { response: D.obj({ message: D.str, updatedRegistration: D.registration, donationId: D.str }) }, v1: pay.unassignDonationFromRegistration,
});
v1Route({
method: 'post', path: '/donations/checkout', tag: 'Payments', summary: 'Give to an event',
description: 'Starts a checkout for a donation of `amount` to `eventId`; answers where to send the person (`redirectUrl`).',
access: 'user', body: z.looseObject({ eventId: id, amount: z.union([z.number(), z.string()]) }), documented: { body: D.obj({ eventId: D.str, amount: D.numeric, provider: D.optStr, successUrl: D.optStr, cancelUrl: D.optStr, failureUrl: D.optStr }), response: D.checkout.extend({ amount: D.rand, event: D.eventRef.partial() }) }, v1: pay.createCheckout,
});
v1Route({
method: 'get', path: '/me/payments', tag: 'Me', summary: 'My payments', access: 'user', query: paymentFilters, documented: { response: D.v1Page(D.payment.extend({ registration: D.registration.nullish() })) }, v1: pay.getUserPayments,
});
v1Route({
method: 'get', path: '/payments/:id', tag: 'Payments', summary: 'One payment',
access: 'user', campusScope: 'scopeEvent(payment, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'payment', ownerOk: true })], params: idParams, documented: { response: D.payment.extend({ registration: D.registration.nullish() }) }, v1: pay.getPaymentById,
});
v1Route({
method: 'post', path: '/payments/:id/receipt', tag: 'Payments', summary: 'Send the receipt again',
access: 'user', campusScope: 'scopeEvent(payment, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'payment', ownerOk: true })], params: idParams,
body: z.looseObject({ channel: z.string().max(20).optional() }), documented: { response: D.message }, v1: pay.sendReceipt,
});
v1Route({
method: 'get', path: '/registrations/:id/payments', tag: 'Payments', summary: 'A registration\'s payments',
access: 'user', campusScope: 'scopeEvent(registration, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'registration', ownerOk: true })], params: idParams,
response: listData, documented: { response: z.array(D.payment) }, map: ctx => ({ params: { registrationId: ctx.params.id } }), v1: pay.getPaymentsByRegistration,
});
v1Route({
method: 'post', path: '/registrations/:id/payment-link', tag: 'Payments', summary: 'Make a link to pay',
description: 'Supervisors: a checkout for `amount` (part of what\'s owed) for someone else to pay. Answers `redirectUrl` (the link) — or `priceUpdated` when an early-bird price ran out.',
access: 'supervisor', campusScope: 'scopeEvent(registration)', middleware: [cs.scopeEvent({ resolve: 'registration' })], params: idParams,
body: z.looseObject({ amount: z.union([z.number(), z.string()]).optional() }), map: ctx => ({ body: { ...(ctx.req.body || {}), registrationId: ctx.params.id } }), documented: { body: D.obj({ amount: D.numeric.optional(), donationAmount: D.numeric.optional(), provider: D.optStr, successUrl: D.optStr, cancelUrl: D.optStr, failureUrl: D.optStr }), response: D.checkout.extend({ priceUpdated: D.optBool, newTotal: D.optRand }) }, v1: pay.createCheckout,
});
v1Route({
method: 'post', path: '/registrations/:id/payment-link/send', tag: 'Payments', summary: 'Send someone a link to pay',
description: 'By email and/or WhatsApp, for what\'s still owing.',
access: 'supervisor', campusScope: 'scopeEvent(registration)', middleware: [cs.scopeEvent({ resolve: 'registration' })], params: idParams,
body: z.looseObject({}), map: ctx => ({ body: { ...(ctx.req.body || {}), registrationId: ctx.params.id } }), documented: { body: D.obj({ redirectUrl: D.str.describe('The link to send'), channel: z.enum(['email', 'whatsapp']) }), response: D.obj({ sent: z.literal(true), channel: D.str }) }, v1: pay.sendPaymentLink,
});
v1Route({
method: 'get', path: '/events/:id/payments', tag: 'Payments', summary: 'An event\'s payments',
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: z.array(D.payment) }, v1: pay.getPaymentsByEvent,
});
v1Route({
method: 'get', path: '/payment-links/:id', tag: 'Payments', summary: 'A pay page',
description: 'What a payment link shows: what\'s owed and the providers to pay with. The id in the link is the credential.',
access: 'public', params: idParams, documented: { response: payPage }, v1: providers.getPayPage,
});
v1Route({
method: 'post', path: '/payment-links/:id', tag: 'Payments', summary: 'Pay from a pay page',
description: 'Starts a checkout with the chosen provider; answers where to send the person.',
access: 'public', params: idParams, body: z.looseObject({ provider: z.string().max(40).optional() }), documented: { response: D.obj({ provider: D.str, redirectUrl: D.optStr, form: D.anyObj.nullish() }) }, v1: providers.choosePayProvider,
});
// ─── Cash-ups ────────────────────────────────────────────────────────────────
const cashupBase = '/events/:id/cashup';
v1Route({
method: 'get', path: '/cashups/audit', tag: 'Cash-ups', summary: 'Cash-up history', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'from', 'to'), documented: { response: z.array(cashupRecord) }, v1: cashup.getCashupAudit,
});
v1Route({
method: 'get', path: cashupBase, tag: 'Cash-ups', summary: 'An event\'s cash-up',
description: 'Income by method, costs, what each drawer should hold, and the saved draft.',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, documented: { response: D.obj({ event: D.anyObj, paymentsByMethod: D.anyObj.describe('Income per method'), totalRevenue: D.rand, costs: z.array(D.anyObj), totalCosts: D.rand, expectedCashByMethod: D.anyObj, reconciled: D.anyObj.nullish().describe('The last close, line by line'), netProfit: D.rand, totalDonations: D.rand, unallocatedDonationsTotal: D.rand, discountsTotal: D.optRand, salesByOption: z.array(D.anyObj).optional() }) }, v1: cashup.getEventCashup,
});
v1Route({
method: 'get', path: `${cashupBase}/cash-by-user`, tag: 'Cash-ups', summary: 'Cash taken by each person',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, documented: { response: D.obj({ rows: z.array(D.anyObj).describe('Per person: who, cash recorded, cash counted'), cashActualTotal: D.optRand, cashDenominations: z.array(D.obj({ value: D.num.describe('The note or coin, in rand'), count: D.num })).describe('Counted cash by note and coin') }) }, v1: cashup.getCashByRecordedUser,
});
v1Route({
method: 'put', path: `${cashupBase}/person-cash/:userId`, tag: 'Cash-ups', summary: 'Count one person\'s cash',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: z.object({ id, userId: id }),
body: z.looseObject({}), map: ctx => ({ params: { eventId: ctx.params.id, userId: ctx.params.userId } }), documented: { body: D.obj({ denominations: z.array(D.obj({ value: D.num.describe('The note or coin, in rand'), count: D.num })), notes: D.optStr }), response: D.anyObj.describe('The saved count') }, v1: cashup.savePersonCash,
});
v1Route({
method: 'put', path: `${cashupBase}/draft`, tag: 'Cash-ups', summary: 'Save the cash-up as a draft',
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, documented: { body: D.obj({ lines: z.array(cashupLineInput), notes: D.optStr }), response: D.message }, v1: cashup.saveEventCashupDraft,
});
v1Route({
method: 'post', path: `${cashupBase}/close`, tag: 'Cash-ups', summary: 'Close the event',
description: 'Admins. Nothing about the event\'s money can change until it\'s reopened.',
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, documented: { body: D.obj({ lines: z.array(cashupLineInput).optional().describe('Per payment method: in the bank, on hand, pending, write-offs (leave out for a quick close)'), notes: D.optStr }), response: cashupRecord }, v1: cashup.closeEvent,
});
v1Route({
method: 'post', path: `${cashupBase}/reopen`, tag: 'Cash-ups', summary: 'Reopen a closed event',
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, documented: { body: D.obj({ notes: D.optStr.describe('Why') }), response: cashupRecord }, v1: cashup.reopenEvent,
});
v1Route({
method: 'post', path: '/cashup-lines/:id/pending', tag: 'Cash-ups', summary: 'Settle a pending cash-up line',
access: 'admin', params: idParams, body: z.looseObject({}), map: ctx => ({ params: { lineId: ctx.params.id } }), documented: { body: D.obj({ outcome: z.enum(['received', 'written_off']), amount: D.numeric, category: D.optStr.describe('A write-off: what it was for'), notes: D.optStr }), response: D.anyObj.describe('The settlement recorded') }, v1: cashup.resolvePending,
});
// ─── Provider transactions ───────────────────────────────────────────────────
v1Route({
method: 'get', path: '/provider-transactions', tag: 'Payments', summary: 'Transactions from Yoco and PayFast',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], query: filters('provider'), documented: { response: txAnswer(z.array(providerTransaction)) }, v1: ptx.getAllProviderTransactions,
});
v1Route({
method: 'get', path: '/provider-transactions/unreconciled', tag: 'Payments', summary: 'Provider transactions not matched yet',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], documented: { response: txAnswer(z.array(providerTransaction)) }, v1: ptx.getUnreconciledProviderTransactions,
});
v1Route({
method: 'post', path: '/provider-transactions/:id/reconcile', tag: 'Payments', summary: 'Match a provider transaction to a registration',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], params: idParams, body: z.looseObject({}), documented: { body: D.obj({ registrationId: D.optStr, eventId: D.optStr.describe('As a donation to this event instead'), userId: D.optStr }), response: txAnswer(D.obj({ transaction: providerTransaction, payment: D.payment, generatedTickets: z.array(D.ticket).optional() })) }, v1: ptx.reconcileProviderTransaction,
});
v1Route({
method: 'post', path: '/provider-transactions/:id/ignore', tag: 'Payments', summary: 'Ignore a provider transaction',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], params: idParams, body: z.looseObject({}), documented: { response: txAnswer(providerTransaction) }, v1: ptx.ignoreProviderTransaction,
});
// ─── Waitlist ────────────────────────────────────────────────────────────────
v1Route({
method: 'post', path: '/waitlist', tag: 'Waitlist', summary: 'Join a sold-out event\'s waitlist',
description: 'Signed in, or with a name and email or phone. When places free up, people are offered them in order.',
access: 'optional', status: 201, body: z.looseObject({ eventId: id }), documented: { body: D.obj({ eventId: D.str, eventOptionId: D.str, variantId: D.optStr, sessionId: D.optStr.describe('Per-session tickets: the session'), quantity: D.numeric, guestName: D.optStr.describe('Not signed in'), guestEmail: D.optStr, guestPhone: D.optStr }), response: waitlistEntry.extend({ ahead: D.num.describe('People ahead'), message: D.str }) }, v1: waitlist.joinWaitlist,
});
v1Route({ method: 'get', path: '/me/waitlist', tag: 'Me', summary: 'Waitlists I\'m on', access: 'user', documented: { response: z.array(waitlistEntry) }, v1: waitlist.myWaitlist });
v1Route({
method: 'get', path: '/waitlist/claims/:token', tag: 'Waitlist', summary: 'A waitlist offer',
description: 'What an offer link shows (the token is the credential). Register with `waitlistToken` to take it.',
access: 'public', middleware: [linkLimiter()], params: tokenParams, documented: { response: D.obj({ eventId: D.optStr, eventTitle: D.optStr, eventStart: D.optDate, eventOptionId: D.str, optionName: D.str, variantId: D.optStr, variantName: D.optStr, sessionId: D.optStr, sessionName: D.optStr, sessionStart: D.optDate, lineName: D.str, quantity: D.optNum.describe('Places offered'), requested: D.num, expiresAt: D.optDate, name: D.optStr }) }, v1: waitlist.claimInfo,
});
v1Route({
method: 'get', path: '/events/:id/waitlist', tag: 'Waitlist', summary: 'An event\'s waitlist',
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, query: filters('all'), map: asEvent, documented: { response: z.array(waitlistEntry.extend({ user: D.userSummary.nullish() })) }, v1: waitlist.eventWaitlist,
});
for (const [action, summary, handler] of [['offer', 'Offer someone their places now', waitlist.offerManually], ['move', 'Move someone up or down the waitlist', waitlist.moveEntry], ['remove', 'Take someone off the waitlist', waitlist.removeEntry]] as const) {
v1Route({
method: 'post', path: `/waitlist/:id/${action}`, tag: 'Waitlist', summary,
access: 'supervisor', campusScope: 'scopeEvent(waitlistEntry)', middleware: [cs.scopeEvent({ resolve: 'waitlistEntry' })], params: idParams, body: z.looseObject({}), v1: handler,
documented: {
body: action === 'offer' ? D.obj({ quantity: D.numeric.optional().describe('Places to offer (default: all they asked for)') })
: action === 'move' ? D.obj({ direction: z.enum(['up', 'down']) }) : D.anyObj,
response: D.message,
},
});
}
v1Route({ method: 'delete', path: '/waitlist/:id', tag: 'Waitlist', summary: 'Leave a waitlist', access: 'user', params: idParams, documented: { response: D.message }, v1: waitlist.leaveWaitlist });
// ─── Sponsorship ─────────────────────────────────────────────────────────────
v1Route({
method: 'get', path: '/events/:id/sponsorship', tag: 'Sponsorship', summary: 'Sponsoring places at an event',
description: 'Whether it\'s on, and the suggested amounts.', access: 'public', params: idParams, map: asEvent, documented: { response: D.obj({ enabled: D.bool, open: D.optBool, spotPrice: D.optRand, showProgress: D.optBool, goalSpots: D.optNum, spotsFunded: D.optNum, sponsors: z.unknown().optional() }) }, v1: sponsor.getEventSponsorship,
});
v1Route({
method: 'get', path: '/events/:id/sponsorship/pool', tag: 'Sponsorship', summary: 'The sponsorship pool',
description: 'What\'s been given and what\'s been used.', access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: D.obj({ eventId: D.str, spotPrice: D.rand, goalSpots: D.optNum }).describe('Plus the pool: given, used and places funded') }, v1: sponsor.getPool,
});
v1Route({
method: 'post', path: '/sponsorships/checkout', tag: 'Sponsorship', summary: 'Give towards places',
description: 'Starts a checkout; answers where to send the person.', access: 'optional', body: z.looseObject({ eventId: id }), documented: { body: D.obj({ eventId: D.str, spots: D.numeric.optional().describe('Places to fund (or amount)'), amount: D.numeric.optional(), anonymous: D.optBool, sponsorName: D.optStr, message: D.optStr, name: D.optStr.describe('Not signed in'), email: D.optStr, phone: D.optStr, provider: D.optStr }), response: D.checkout.extend({ amount: D.rand }) }, v1: sponsor.createSponsorCheckout,
});
// ─── Consent ─────────────────────────────────────────────────────────────────
v1Route({
method: 'get', path: '/events/:id/consent', tag: 'Consent', summary: 'Parental consent for an event',
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: D.obj({ event: D.anyObj, counts: z.record(z.string(), z.number()).describe('Status → how many'), rows: z.array(consentRow) }) }, v1: consent.listEventConsent,
});
v1Route({
method: 'get', path: '/events/:id/consent/medical', tag: 'Consent', summary: 'Medical notes from consent forms',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: D.obj({ event: D.eventRef.partial(), rows: z.array(D.obj({ name: D.str, dateOfBirth: D.optDate, parentName: D.optStr, parentPhone: D.optStr, parentEmail: D.optStr, medical: D.anyObj.nullish(), emergency: D.anyObj.nullish(), photoConsent: D.optBool, purged: D.bool.describe('Medical details removed after the event') })) }) }, v1: consent.medicalList,
});
v1Route({
method: 'post', path: '/events/:id/consent/resend-pending', tag: 'Consent', summary: 'Remind everyone who hasn\'t answered',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: z.looseObject({}), map: asEvent, documented: { response: D.obj({ message: D.str, sent: D.num, pending: D.num }) }, v1: consent.resendPending,
});
v1Route({
method: 'post', path: '/events/:id/consent/rerequest', tag: 'Consent', summary: 'Ask for consent again',
description: 'After the consent wording changed.', access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams,
body: z.looseObject({}), map: ctx => ({ params: { eventId: ctx.params.id }, body: ctx.req.body }), documented: { response: D.obj({ message: D.str, created: D.num }) }, v1: consent.rerequestConsent,
});
v1Route({
method: 'post', path: '/consent-requests/:id/resend', tag: 'Consent', summary: 'Send a consent request again',
access: 'user', campusScope: 'scopeEvent(consentRequest, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'consentRequest', ownerOk: true })], params: idParams,
body: z.looseObject({}), documented: { response: D.message }, v1: consent.resendRequest,
});
v1Route({
method: 'get', path: '/consent/:token', tag: 'Consent', summary: 'A consent form',
description: 'What the link in a consent request shows (the token is the parent\'s credential).',
access: 'public', middleware: [linkLimiter()], params: tokenParams, documented: { response: D.obj({ status: D.str, superseded: D.bool, expired: D.bool, respondedAt: D.optDate, attendeeName: D.str, parentName: D.optStr, consentText: D.str, textVersion: D.optNum, event: D.obj({ title: D.str, startDate: D.date, endDate: D.date, location: D.optStr }), collect: D.obj({ medical: D.bool, emergency: D.bool, photo: D.bool }).describe('What the form asks for') }) }, v1: consent.getConsent,
});
v1Route({
method: 'post', path: '/consent/:token', tag: 'Consent', summary: 'Answer a consent form',
access: 'public', middleware: [linkLimiter()], params: tokenParams, body: z.looseObject({}), documented: { body: D.obj({ decision: z.enum(['give', 'decline']), signedName: D.str, isGuardian: D.bool.describe('Must be true'), medical: D.obj({ allergies: D.optStr, medication: D.optStr, conditions: D.optStr, medicalAid: D.optStr }).optional(), emergency: D.obj({ name: D.str, phone: D.str, relationship: D.optStr }).optional(), photoConsent: D.optBool }), response: D.obj({ message: D.str, status: z.enum(['given', 'declined']) }) }, v1: consent.answerConsent,
});
// ─── Attendee form answers ───────────────────────────────────────────────────
v1Route({
method: 'get', path: '/form-responses', tag: 'Registrations', summary: 'Attendee form answers',
description: 'For an event (`eventId`) or a registration (`registrationId`), for events you may see.',
access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'registrationId', 'userId', 'cursor'), documented: { response: D.obj({ items: z.array(D.obj({ id: D.str, registrationId: D.str, attendeeId: D.optStr, createdAt: D.date, answers: z.array(D.obj({ fieldId: D.str, value: D.str })).optional() })), nextCursor: D.optStr.describe('Pass as `cursor` for the next page') }) }, v1: forms.listFormResponses,
});
// ─── Feedback ────────────────────────────────────────────────────────────────
v1Route({
method: 'get', path: '/feedback/summary', tag: 'Feedback', summary: 'Feedback across events', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventIds'), documented: { response: z.array(D.obj({ eventId: D.str, title: D.str, startDate: D.date, anonymous: D.bool, sentAt: D.optDate, invited: D.num, responses: D.num, responseRate: D.optNum.describe('%'), questions: z.array(D.anyObj).describe('Each question with its answers summarised') })) }, v1: feedback.getSummary,
});
v1Route({
method: 'get', path: '/feedback-forms/:token', tag: 'Feedback', summary: 'A feedback form',
description: 'What the link sent after an event shows (the token is the credential).', access: 'public', params: tokenParams, documented: { response: D.obj({ event: D.anyObj, name: D.str, intro: D.optStr, anonymous: D.bool, responded: D.bool, fields: z.array(feedbackField) }) }, v1: feedback.getPublicForm,
});
v1Route({
method: 'post', path: '/feedback-forms/:token', tag: 'Feedback', summary: 'Answer a feedback form',
access: 'public', params: tokenParams, body: z.looseObject({}), documented: { body: D.obj({ answers: z.record(z.string(), z.unknown()).describe('Field id → answer') }), response: D.message }, v1: feedback.submitPublicForm,
});
v1Route({
method: 'get', path: '/events/:id/feedback', tag: 'Feedback', summary: 'An event\'s feedback set-up and answers',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: feedbackForm }, v1: feedback.getEventFeedback,
});
v1Route({
method: 'put', path: '/events/:id/feedback', tag: 'Feedback', summary: 'Set up an event\'s feedback form',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, documented: { body: D.obj({ sendAt: D.optDate, audience: D.optStr.describe('checked_in or everyone'), anonymous: D.optBool, intro: D.optStr, fields: z.array(feedbackField) }), response: feedbackForm }, v1: feedback.saveEventFeedback,
});
v1Route({
method: 'delete', path: '/events/:id/feedback', tag: 'Feedback', summary: 'Remove an event\'s feedback form',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, documented: { response: D.message }, v1: feedback.deleteEventFeedback,
});
v1Route({
method: 'post', path: '/events/:id/feedback/send-now', tag: 'Feedback', summary: 'Send the feedback form now',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, documented: { response: D.obj({ invited: D.num, message: D.str }) }, v1: feedback.sendNow,
});
+187
View File
@@ -0,0 +1,187 @@
/**
* People and messaging on v2 (1.51.14): the rest of accounts (password reset, activation,
* two-step sign-in, sessions, closing an account), messaging attendees, email and WhatsApp
* broadcasts, scheduled messages, automations, the site banner, saved filters, the dashboards,
* stats, analytics and report files. Each route runs the v1 handler (viaV1.ts) behind the same
* access and campus checks as v1.
*/
import { z } from 'zod';
import type { Request } from 'express';
import { id, idParams } from '../schemas';
import { v1Route } from '../viaV1';
import * as D from '../docs';
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
const rateLimit = require('express-rate-limit') as (opts: object) => any;
const { loginLimiter } = require('../../middleware/authMiddleware') as { loginLimiter: any };
const cs = require('../../utils/campusScope') as {
scopeEvent(opts?: { need?: 'view' | 'manage'; from?: (req: any) => unknown }): any;
scopeBroadcast(opts: { freeformField: string }): any;
scopeList: any;
unscopedOnly: any;
campusIndependent(reason: string): any;
};
const users = require('../../controllers/userController');
const events = require('../../controllers/eventController');
const wa = require('../../controllers/whatsappController');
const bc = require('../../controllers/broadcastController');
const wbc = require('../../controllers/whatsappBroadcastController');
const sched = require('../../controllers/scheduledEmailsController');
const auto = require('../../controllers/automationController');
const banner = require('../../controllers/bannerController');
const stats = require('../../controllers/statsController');
const analytics = require('../../controllers/analyticsController');
const reports = require('../../controllers/reportController');
// These two keep their handlers in the route file; v2 runs the v1 router with the v1 path.
const savedFilterRouter = require('../../routes/savedFilterRoutes');
const dashboardRouter = require('../../routes/dashboardRoutes');
const loose = z.looseObject({});
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
// ─── Documented shapes (docs.ts) ─────────────────────────────────────────────
const challenge = D.obj({ challengeId: D.str, method: z.enum(['email', 'whatsapp']), destination: D.str.describe('Where the code went, partly hidden') });
const signedIn = D.obj({ message: D.optStr, id: D.str, name: D.str, email: D.str, role: D.str, token: D.str.describe('A new sign-in token') });
const kind = z.enum(['update', 'news']).describe('update: about something they booked (always sent). news: needs their news consent');
const attendeeFilter = D.obj({
status: D.optStr.describe('any, paid, unpaid, checked_in, not_checked_in…'), attendeeIds: D.ids.optional(), registrationIds: D.ids.optional(),
allocationGroupId: D.optStr.describe('People in one room or group'), name: D.optStr, email: D.optStr, phone: D.optStr,
audience: z.enum(['sponsors']).optional().describe('Sponsors of the event instead'),
});
const emailAttendees = D.obj({
subject: D.optStr, html: D.optStr, text: D.optStr.describe('subject and html or text, unless template is tickets'),
template: z.enum(['custom', 'tickets', 'payment_reminder', 'event_reminder']).optional().describe('tickets: send everyone their tickets; the reminders fill in a standard message'), filter: attendeeFilter.optional(),
dryRun: D.optBool.describe('Only say who it would reach'), kind: kind.optional(), promoEventId: D.optStr.describe('Promote another event (news)'),
});
const whatsappAttendees = D.obj({
message: D.optStr.describe('Required unless template is tickets'), template: z.enum(['custom', 'tickets', 'payment_reminder', 'event_reminder']).optional(), filter: attendeeFilter.optional(),
dryRun: D.optBool, kind: kind.optional(), promoEventId: D.optStr,
});
const reach = D.obj({ eventId: D.optStr, kind: D.optStr, matched: D.num, skipped: D.optNum.describe('Left out: no news consent'), recipients: z.array(D.anyObj).optional().describe('The first 20'), queued: D.optNum, template: D.optStr, sent: D.optNum });
const scheduledJob = D.obj({
id: D.str, kind: D.str.describe('broadcast or attendees'), eventId: D.optStr, broadcast: D.bool, channel: z.enum(['email', 'whatsapp']).or(D.str),
recipient: D.optStr, scheduledAt: D.date, createdAt: D.date, status: D.str.describe('queued, sent, failed, cancelled'), attempts: D.num,
sentAt: D.optDate, lastError: D.optStr, subject: D.str, html: D.str, text: D.str, message: D.str, hasHtml: D.bool, hasText: D.bool,
});
const scheduled = D.obj({ message: D.str, job: D.anyObj });
const bannerShape = D.obj({ message: D.str, type: D.str.describe('info, warning, success, error'), liveFrom: D.optDate, liveTill: D.optDate });
const savedFilter = D.obj({ id: D.str, page: D.str.describe('The list page it belongs to'), name: D.str, params: D.anyObj.describe('The filters'), isDefault: D.bool, updatedAt: D.date });
const scanStats = D.obj({ totalToday: D.num, myToday: D.num, lastHour: D.num, byStaff: z.array(D.anyObj) }).partial();
const monthly = D.obj({ thisMonth: D.num, lastMonth: D.num, pctChange: D.optNum });
const reportBody = D.obj({
title: D.str, subtitle: D.optStr, kind: z.enum(['table', 'layered']),
table: D.obj({ columns: z.array(D.str), rows: z.array(z.array(z.unknown())) }).optional().describe('kind table'),
layered: D.obj({ header: D.optStr, sections: z.array(D.anyObj) }).optional().describe('kind layered: titled sections, each with its rows'),
orientation: z.enum(['portrait', 'landscape']).optional(), stats: z.array(D.anyObj).optional().describe('Figures shown above the table'),
chart: D.anyObj.optional(), note: D.optStr, extraTables: z.array(D.anyObj).optional(),
});
/** The query string of the v2 request, for a v1 router's URL. */
const qs = (req: Request) => (req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '');
// ─── Accounts ────────────────────────────────────────────────────────────────
v1Route({ method: 'post', path: '/auth/login/code/resend', tag: 'Accounts', summary: 'Send the sign-in code again', access: 'public', middleware: [loginLimiter], body: loose, documented: { body: D.obj({ challengeId: D.str }), response: challenge }, v1: users.resendLoginTwoFactor });
v1Route({ method: 'post', path: '/auth/password/forgot', tag: 'Accounts', summary: 'Email me a link to reset my password', description: 'Always the same answer, so it doesn\'t say whether an account has that email. Rate-limited like sign-in.', access: 'public', middleware: [loginLimiter], body: z.looseObject({ email: z.string().max(200) }), documented: { response: D.message }, v1: users.requestPasswordReset });
v1Route({ method: 'post', path: '/auth/password/reset', tag: 'Accounts', summary: 'Set a new password with a reset link', access: 'public', body: loose, documented: { body: D.obj({ token: D.str.describe('From the link'), password: D.str }), response: D.message }, v1: users.resetPassword });
v1Route({ method: 'post', path: '/auth/activate', tag: 'Accounts', summary: 'Activate an account with its link', access: 'public', body: loose, documented: { body: D.obj({ token: D.str.describe('From the link'), password: D.str.describe('At least 8 characters'), acceptTerms: D.optBool }), response: signedIn }, v1: users.activateAccount });
v1Route({ method: 'post', path: '/me/sessions/revoke', tag: 'Me', summary: 'Sign out everywhere else', access: 'user', body: loose, documented: { response: D.obj({ message: D.str, token: D.str.describe('A new token for this device (the old one stops working)') }) }, v1: users.revokeMySession });
v1Route({ method: 'post', path: '/me/close', tag: 'Me', summary: 'Close my account', description: 'Needs the password. Personal details are removed; payment records stay, anonymised.', access: 'user', body: loose, documented: { body: D.obj({ password: D.str, deleteData: D.optBool.describe('Also ask for my personal information to be deleted') }), response: D.message }, v1: users.closeAccount });
v1Route({ method: 'get', path: '/me/activity', tag: 'Me', summary: 'Recent sign-ins and changes to my account', access: 'user', documented: { response: z.array(D.obj({ id: D.str, type: D.str.describe('login, password_changed, two_factor_enabled…'), device: D.optStr, createdAt: D.date })) }, v1: users.getMyActivity });
v1Route({ method: 'post', path: '/me/verify-password', tag: 'Me', summary: 'Check my password (before a sensitive change)', access: 'user', middleware: [loginLimiter], body: loose, documented: { body: D.obj({ password: D.str }), response: D.ok }, v1: users.verifyPassword });
v1Route({ method: 'post', path: '/me/two-factor/enable', tag: 'Me', summary: 'Start turning on two-step sign-in', description: 'Sends a code to confirm with.', access: 'user', middleware: [loginLimiter], body: loose, documented: { body: D.obj({ method: z.enum(['email', 'whatsapp']), password: D.str }), response: challenge }, v1: users.startEnableTwoFactor });
v1Route({ method: 'post', path: '/me/two-factor/confirm', tag: 'Me', summary: 'Confirm two-step sign-in with the code', access: 'user', middleware: [loginLimiter], body: loose, documented: { body: D.obj({ challengeId: D.str, code: D.str }), response: D.obj({ twoFactorEnabled: z.literal(true), twoFactorMethod: D.str }) }, v1: users.confirmEnableTwoFactor });
v1Route({ method: 'post', path: '/me/two-factor/disable', tag: 'Me', summary: 'Turn off two-step sign-in', access: 'user', middleware: [loginLimiter], body: loose, documented: { body: D.obj({ password: D.str }), response: D.obj({ twoFactorEnabled: z.literal(false), twoFactorMethod: z.null() }) }, v1: users.disableTwoFactor });
v1Route({
method: 'get', path: '/users/exists', tag: 'Accounts', summary: 'Is there an account with this email or phone?',
access: 'supervisor', campusScope: 'campusIndependent (accounts are shared across campuses)', middleware: [cs.campusIndependent('accounts are shared across campuses')],
query: filters('email', 'phone', 'phoneNumber'), documented: { response: D.obj({ exists: D.bool, hasEmail: D.optBool, hasPhone: D.optBool, user: D.obj({ id: D.str, name: D.str, email: D.optStr, phoneNumber: D.optStr, notificationPreference: D.optStr }).nullish() }) }, v1: users.checkUserExists,
});
v1Route({
method: 'get', path: '/users', tag: 'Accounts', summary: 'Every account',
description: 'Paged (`page`, `limit`) and searchable (`q`, `role`, `campus`, …) — see v1 for every filter.',
access: 'supervisor', campusScope: 'campusIndependent (accounts are shared across campuses)', middleware: [cs.campusIndependent('accounts are shared across campuses')],
query: filters('page', 'limit', 'search', 'role', 'isActive', 'campus'), documented: { response: D.v1Page(D.user) }, v1: users.getUsers,
});
v1Route({ method: 'get', path: '/users/:id', tag: 'Accounts', summary: 'One account', access: 'admin', params: idParams, documented: { response: D.user.extend({ scopedCampusIds: D.ids.optional().describe('Campuses they\'re limited to') }) }, v1: users.getUserById });
v1Route({ method: 'patch', path: '/users/:id', tag: 'Accounts', summary: 'Change an account', description: 'Name, contact details, role, campus limits, active.', access: 'admin', params: idParams, body: loose, documented: { body: D.obj({ name: D.optStr, email: D.optStr, phoneNumber: D.optStr, role: D.optStr.describe('admin, supervisor, staff or user'), isActive: D.optBool, password: D.optStr.describe('Set a new password'), notificationPreference: D.optStr.describe('email, whatsapp or both'), twoFactorEnabled: D.optBool.describe('Only to turn it off'), scopedCampusIds: D.ids.optional().describe('Limit staff to these campuses (empty: all)') }), response: D.user }, v1: users.updateUser });
v1Route({ method: 'delete', path: '/users/:id', tag: 'Accounts', summary: 'Delete an account', access: 'admin', params: idParams, documented: { response: D.message }, v1: users.deleteUser });
v1Route({ method: 'post', path: '/users/:id/sessions/revoke', tag: 'Accounts', summary: 'Sign someone out everywhere', access: 'admin', params: idParams, body: loose, documented: { response: D.message }, v1: users.adminRevokeUserSessions });
v1Route({ method: 'post', path: '/users/:id/anonymize', tag: 'Accounts', summary: 'Anonymise an account', description: 'Admins. Personal details are removed for good; payment records stay.', access: 'admin', params: idParams, body: loose, documented: { response: D.message }, v1: users.anonymizeUser });
// ─── Messaging an event's people ─────────────────────────────────────────────
const toEvent = { campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()] };
v1Route({
method: 'get', path: '/event-listing', tag: 'Events', summary: 'Upcoming public events in full',
description: 'What the site\'s event lists show (ticket options, prices, early-bird tiers), not paged. `campus` (id or slug) and `series` (slug) narrow it. For a paged, slimmer list use GET /events.',
access: 'public', query: filters('campus', 'series'), documented: { response: z.array(D.event.extend({ eventOptions: z.array(D.eventOption).optional(), isSoldOut: D.optBool })) }, v1: events.getEvents,
});
v1Route({ method: 'get', path: '/admin/events', tag: 'Events (staff)', summary: 'Every event, including deactivated ones', access: 'admin', documented: { response: z.array(D.event) }, v1: events.getAllEvents });
v1Route({ method: 'post', path: '/events/:id/email-attendees', tag: 'Messaging', summary: 'Email an event\'s people', description: 'Filtered by status or chosen people; news consent is checked.', access: 'supervisor', ...toEvent, params: idParams, body: loose, documented: { body: emailAttendees, response: reach }, v1: events.emailEventAttendees });
v1Route({ method: 'post', path: '/events/:id/email-attendees/schedule', tag: 'Messaging', summary: 'Schedule an email to an event\'s people', access: 'supervisor', ...toEvent, params: idParams, body: loose, documented: { body: emailAttendees.extend({ scheduledAt: D.date }), response: scheduled }, v1: events.scheduleEmailEventAttendees });
v1Route({ method: 'post', path: '/events/:id/whatsapp-attendees', tag: 'Messaging', summary: 'WhatsApp an event\'s people', access: 'supervisor', ...toEvent, params: idParams, body: loose, documented: { body: whatsappAttendees, response: reach }, v1: events.whatsappEventAttendees });
v1Route({ method: 'post', path: '/events/:id/whatsapp-attendees/schedule', tag: 'Messaging', summary: 'Schedule a WhatsApp to an event\'s people', access: 'supervisor', ...toEvent, params: idParams, body: loose, documented: { body: whatsappAttendees.extend({ scheduledAt: D.date }), response: scheduled }, v1: events.scheduleWhatsappEventAttendees });
for (const [channel, field, ctrl] of [['email', 'emails', bc], ['whatsapp', 'phones', wbc]] as const) {
const scope = { campusScope: `scopeBroadcast(freeform ${field})`, middleware: [cs.scopeBroadcast({ freeformField: field })] };
const [preview, send, schedule] = channel === 'email' ? [ctrl.previewBroadcast, ctrl.sendBroadcast, ctrl.scheduleBroadcast] : [ctrl.previewWhatsAppBroadcast, ctrl.sendWhatsAppBroadcast, ctrl.scheduleWhatsAppBroadcast];
const name = channel === 'email' ? 'an email' : 'a WhatsApp';
const who = D.obj({
userIds: D.ids.optional().describe('Chosen accounts'),
...(channel === 'email' ? { emails: D.optStr.describe('Typed-in addresses, separated by commas or new lines') } : { phones: D.optStr.describe('Typed-in numbers, separated by commas or new lines') }),
eventId: D.optStr.describe('Everyone registered for this event'), kind: kind.optional().describe('Default news'),
});
const content = channel === 'email' ? { subject: D.str, html: D.optStr, text: D.optStr } : { message: D.str };
v1Route({ method: 'post', path: `/broadcasts/${channel}/preview`, tag: 'Messaging', summary: `Who ${name} broadcast would reach`, access: 'supervisor', ...scope, body: loose, documented: { body: who, response: reach.extend({ event: D.anyObj.nullish(), placeholders: z.array(D.str).optional() }) }, v1: preview });
v1Route({ method: 'post', path: `/broadcasts/${channel}/send`, tag: 'Messaging', summary: `Send ${name} broadcast`, description: 'To campuses, events, roles or listed people; news consent is checked.', access: 'supervisor', ...scope, body: loose, documented: { body: who.extend(content), response: reach }, v1: send });
v1Route({ method: 'post', path: `/broadcasts/${channel}/schedule`, tag: 'Messaging', summary: `Schedule ${name} broadcast`, access: 'supervisor', ...scope, body: loose, documented: { body: who.extend(content).extend({ scheduledAt: D.date }), response: scheduled }, v1: schedule });
}
v1Route({ method: 'get', path: '/scheduled-messages', tag: 'Messaging', summary: 'Scheduled emails and WhatsApps', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('status', 'eventId', 'channel'), documented: { response: D.obj({ jobs: z.array(scheduledJob) }) }, v1: sched.listScheduledEmails });
v1Route({ method: 'patch', path: '/scheduled-messages/:id', tag: 'Messaging', summary: 'Change a scheduled message', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], params: idParams, body: loose, documented: { body: D.obj({ scheduledAt: D.optDate, subject: D.optStr, html: D.optStr, text: D.optStr, message: D.optStr.describe('WhatsApp') }), response: D.obj({ message: D.str, job: scheduledJob }) }, v1: sched.updateScheduledEmail });
v1Route({ method: 'delete', path: '/scheduled-messages/:id', tag: 'Messaging', summary: 'Cancel a scheduled message', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], params: idParams, documented: { response: D.message }, v1: sched.deleteScheduledEmail });
v1Route({
method: 'post', path: '/automations/schedule', tag: 'Messaging', summary: 'Schedule an event\'s reminder messages',
access: 'supervisor', campusScope: 'scopeEvent(body.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.body?.eventId })], body: z.looseObject({ eventId: id }), documented: { body: D.obj({ eventId: D.str, jobs: z.array(D.obj({ scheduledAt: D.date, subject: D.str, html: D.optStr, text: D.optStr, audience: z.enum(['sponsors']).optional(), promoEventId: D.optStr })).min(1) }), response: D.obj({ message: D.str, jobs: z.array(D.anyObj) }) }, v1: auto.scheduleAutomations,
});
// ─── WhatsApp connection (admins) ────────────────────────────────────────────
v1Route({ method: 'get', path: '/whatsapp/config', tag: 'WhatsApp', summary: 'The WhatsApp connection settings', access: 'admin', documented: { response: D.obj({ tokenMasked: D.str, instanceId: D.str, hasToken: D.bool, hasInstance: D.bool, configured: D.bool, webhookUrl: D.optStr }) }, v1: wa.getConfigHandler });
v1Route({ method: 'put', path: '/whatsapp/config', tag: 'WhatsApp', summary: 'Change the WhatsApp connection settings', access: 'admin', body: loose, documented: { body: D.obj({ token: D.optStr, instanceId: D.optStr }), response: D.message }, v1: wa.saveConfigHandler });
v1Route({ method: 'post', path: '/whatsapp/instance', tag: 'WhatsApp', summary: 'Create the WhatsApp instance', access: 'admin', body: loose, documented: { body: D.obj({ name: D.optStr }), response: D.obj({ message: D.str }).describe('Plus what the WhatsApp service answered') }, v1: wa.createInstanceHandler });
v1Route({ method: 'delete', path: '/whatsapp/instance', tag: 'WhatsApp', summary: 'Delete the WhatsApp instance', access: 'admin', documented: { response: D.obj({ message: D.str }).describe('Plus what the WhatsApp service answered') }, v1: wa.deleteInstanceHandler });
v1Route({ method: 'get', path: '/whatsapp/status', tag: 'WhatsApp', summary: 'Is WhatsApp connected?', access: 'admin', documented: { response: D.anyObj.describe('The WhatsApp service\'s status answer') }, v1: wa.getStatusHandler });
v1Route({ method: 'get', path: '/whatsapp/qr', tag: 'WhatsApp', summary: 'The QR code to link a phone', access: 'admin', documented: { response: D.anyObj.describe('The WhatsApp service\'s answer, with the QR code image') }, v1: wa.getQrHandler });
for (const [action, summary, handler] of [['request-code', 'Link a phone with a code instead', wa.requestCodeHandler], ['logout', 'Unlink the phone', wa.logoutHandler], ['start', 'Start the WhatsApp session', wa.startHandler], ['restart', 'Restart the WhatsApp session', wa.restartHandler]] as const) {
v1Route({
method: 'post', path: `/whatsapp/${action}`, tag: 'WhatsApp', summary, access: 'admin', body: loose,
documented: { body: action === 'request-code' ? D.obj({ phoneNumber: D.str }) : D.anyObj, response: D.anyObj.describe('The WhatsApp service\'s answer') },
v1: handler,
});
}
// ─── Banner, saved filters, dashboards, stats, analytics, reports ────────────
v1Route({ method: 'get', path: '/banner', tag: 'Site', summary: 'The site-wide banner', access: 'public', documented: { response: bannerShape }, v1: banner.getBanner });
v1Route({ method: 'put', path: '/banner', tag: 'Site', summary: 'Set the site-wide banner', access: 'supervisor', campusScope: 'unscopedOnly (admins and unscoped supervisors)', middleware: [cs.unscopedOnly], body: loose, documented: { body: bannerShape.partial().describe('An empty message takes the banner down'), response: bannerShape }, v1: banner.setBanner });
const OWN = 'campusIndependent (a person\'s own saved filters)';
v1Route({ method: 'get', path: '/saved-filters', tag: 'Me', summary: 'My saved filters', description: 'For one list page (`page`).', access: 'staff', campusScope: OWN, query: filters('page'), documented: { response: z.array(savedFilter) }, v1: savedFilterRouter, map: ctx => ({ url: `/${qs(ctx.req)}` }) });
v1Route({ method: 'post', path: '/saved-filters', tag: 'Me', summary: 'Save a filter', description: 'By name (replaces one with the same name); up to 30 per page.', access: 'staff', campusScope: OWN, body: loose, documented: { body: D.obj({ page: D.str, name: D.str, params: D.anyObj, isDefault: D.optBool }), response: savedFilter }, v1: savedFilterRouter, map: () => ({ url: '/' }) });
v1Route({ method: 'put', path: '/saved-filters/:id', tag: 'Me', summary: 'Change a saved filter', access: 'staff', campusScope: OWN, params: idParams, body: loose, documented: { body: D.obj({ name: D.optStr, params: D.anyObj.optional(), isDefault: D.optBool }), response: savedFilter }, v1: savedFilterRouter, map: ctx => ({ url: `/${encodeURIComponent(ctx.params.id)}` }) });
v1Route({ method: 'delete', path: '/saved-filters/:id', tag: 'Me', summary: 'Delete a saved filter', access: 'staff', campusScope: OWN, params: idParams, documented: { response: D.ok }, v1: savedFilterRouter, map: ctx => ({ url: `/${encodeURIComponent(ctx.params.id)}` }) });
v1Route({ method: 'get', path: '/dashboard/today', tag: 'Dashboard', summary: 'Today at a glance', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], documented: { response: D.obj({ events: z.array(D.obj({ id: D.str, title: D.str, startsAt: D.date, endsAt: D.date, sessionLabel: D.optStr, places: D.num, checkedIn: D.num })).describe('Today\'s events with places and check-ins'), money: D.obj({ today: D.rand, todayCount: D.num, week: D.rand }).nullish(), pending: z.array(D.obj({ key: D.str, label: D.str, count: D.num, href: D.str, tone: D.optStr })).describe('What\'s waiting for you') }) }, v1: dashboardRouter, map: ctx => ({ url: `/today${qs(ctx.req)}` }) });
v1Route({ method: 'get', path: '/dashboard/search', tag: 'Dashboard', summary: 'Search people, registrations and events', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('q'), documented: { response: D.obj({ results: z.array(D.obj({ type: D.str.describe('event, payment, person, registration'), id: D.str, title: D.str, subtitle: D.optStr, href: D.optStr })) }) }, v1: dashboardRouter, map: ctx => ({ url: `/search${qs(ctx.req)}` }) });
v1Route({ method: 'get', path: '/stats/staff', tag: 'Dashboard', summary: 'Staff dashboard numbers', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], documented: { response: D.obj({ scanStats, recentScans: z.array(D.anyObj) }) }, v1: stats.getStaffDashboardStats });
v1Route({ method: 'get', path: '/stats/supervisor', tag: 'Dashboard', summary: 'Supervisor dashboard numbers', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], documented: { response: D.obj({ scanStats, recentScans: z.array(D.anyObj), paymentStats: D.anyObj, activeEventsCount: D.num }) }, v1: stats.getSupervisorDashboardStats });
v1Route({ method: 'get', path: '/stats/admin', tag: 'Dashboard', summary: 'Admin dashboard numbers', access: 'admin', documented: { response: D.obj({ scanStats, recentScans: z.array(D.anyObj), paymentStats: D.anyObj, activeEventsCount: D.num }) }, v1: stats.getAdminDashboardStats });
v1Route({ method: 'get', path: '/stats/overview', tag: 'Dashboard', summary: 'Overview numbers', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('from', 'to', 'campus'), documented: { response: D.obj({ funnel: D.anyObj.nullish(), activeEvents: D.num, registrations: monthly, ticketsSold: monthly, revenue: monthly, donations: monthly, trend: z.array(D.obj({ date: D.str, revenue: D.rand })), topEvents: z.array(D.obj({ eventId: D.str, title: D.str, revenue: D.rand, registrations: D.num, ticketsSold: D.num })) }) }, v1: stats.getOverviewStats });
const trackLimiter = rateLimit({ windowMs: 60 * 1000, max: 60, standardHeaders: true, legacyHeaders: false });
v1Route({ method: 'post', path: '/analytics/track', tag: 'Dashboard', summary: 'Record a step on the way to registering', access: 'public', middleware: [trackLimiter], body: loose, documented: { body: D.obj({ sessionId: D.str.describe('The browser\'s analytics id'), eventId: D.str, stage: z.enum(['view', 'start', 'submit', 'paid']), source: D.optStr.describe('Where they came from') }), response: z.null().describe('Always 204, no content') }, v1: analytics.trackStage });
v1Route({ method: 'get', path: '/analytics/funnel', tag: 'Dashboard', summary: 'How many get from viewing to paying', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventIds', 'eventId', 'from', 'to'), documented: { response: D.obj({ perEvent: z.array(D.anyObj).describe('The same for each event') }).describe('Counts per stage (view, start, submit, paid), sources and devices, and per ticket') }, v1: analytics.getFunnel });
v1Route({ method: 'post', path: '/reports/pdf', tag: 'Reports', summary: 'A report as a PDF', description: 'The report\'s rows and columns in the body.', access: 'user', body: loose, file: { contentType: 'application/pdf' }, documented: { body: reportBody }, v1: reports.generatePdf });
v1Route({ method: 'post', path: '/reports/excel', tag: 'Reports', summary: 'A report as an Excel file', access: 'user', body: loose, file: { contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, documented: { body: reportBody }, v1: reports.generateExcel });
v1Route({ method: 'post', path: '/reports/email', tag: 'Reports', summary: 'Email a report as a PDF', access: 'user', body: loose, documented: { body: reportBody.extend({ subject: D.optStr, body: D.optStr.describe('Text of the email') }), response: D.message }, v1: reports.emailPdf });
v1Route({ method: 'post', path: '/reports/whatsapp', tag: 'Reports', summary: 'WhatsApp a report as a PDF', access: 'user', body: loose, documented: { body: reportBody.extend({ caption: D.optStr }), response: D.message }, v1: reports.whatsappPdf });
@@ -1,4 +1,5 @@
import { z } from 'zod';
import * as D from '../docs';
import type { Request } from 'express';
import { defineRoute, type ApiUser } from '../route';
import { pageOf, single } from '../paging';
@@ -207,6 +208,16 @@ export const bulk = defineRoute({
access: 'supervisor', campusScope: 'scopeBody(registrationIds, deny orphans)', middleware: [campusScope.scopeBody([['registrationIds', 'registration']], { orphan: 'deny' })],
body: z.looseObject({ action: z.enum(['markPaid', 'cancel', 'moveOption', 'allocate', 'message']), registrationIds: z.array(id).min(1).max(500) }),
response: single(z.looseObject({})),
documented: {
body: D.obj({
action: z.enum(['markPaid', 'cancel', 'moveOption', 'allocate', 'message']), registrationIds: z.array(id).min(1).max(500),
method: D.optStr.describe('markPaid: cash, eft, card or other'), paidAt: D.optDate.describe('markPaid'),
fromOptionId: D.optStr.describe('moveOption'), toOptionId: D.optStr.describe('moveOption'),
setId: D.optStr.describe('allocate'), groupId: D.optStr.describe('allocate'),
channel: z.enum(['email', 'whatsapp']).optional().describe('message'), subject: D.optStr.describe('message (email)'), text: D.optStr.describe('message'),
}),
response: D.obj({ action: D.str, done: D.num, skipped: D.num, failed: D.num, results: z.array(D.obj({ id: D.str, ok: D.bool, skipped: D.optBool, message: D.optStr })) }),
},
async handler({ body, req }) {
const { runBulk } = require('../../utils/bulkRegistrations') as { runBulk(req: unknown, body: object): Promise<object> };
return { data: plain(await runBulk(req, body)) };

Some files were not shown because too many files have changed in this diff Show More