Compare commits

..
Author SHA1 Message Date
joshua 316a2b40a1 Merge branch 'fix/feedback-background-send' 2026-11-03 14:11:44 +02:00
joshuaandClaude Opus 5.5 73547a0f8c Feedback Send now and Remind run in the background (2.1.1)
- Send now and Remind answer straight away with who they're going to and
  queue the feedback-send task; the Feedback page refreshes to show progress.
- One-off background tasks (enqueueTask in src/jobs/index.js): pg-boss with
  retries, or in-process after the response when the queue is off.
- Reminders are claimed with the 24-hour rule, so two tasks at once never
  remind the same person twice.
- Tests: tests/tasks.test.js, tests/feedbackPage.test.ts, e2e/feedback.spec.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:11:44 +02:00
joshuaandClaude Opus 5.5 b5821e1c18 Handover: Hub 1.7.0 bills on the income bands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:42:46 +02:00
joshua 84cf37d31d Merge branch 'feat/feedback-page' 2026-11-03 13:03:05 +02:00
joshuaandClaude Opus 5.5 84a1a9084c Feedback page: results, responses, who hasn't answered, reminders (2.1.0)
- Supervisor -> Feedback (supervisors and admins, campus-scoped): every
  event's form with answered of asked, response rate, average rating and
  last reminder; per event the results per question (with the AI summary),
  each response, and on named forms who was asked and who has answered.
- Remind those who haven't answered (or one person on a named form): a
  fresh link, plus the form to anyone eligible who hasn't had it; nobody gets
  the form or a reminder twice within 24 hours. The 3-day job is unchanged.
- Anonymous forms show no names and no list of who has answered.
- FeedbackInvite.reminderCount (migration 20261103120000_feedback_reminders);
  access log action feedback_responses; help page; dashboard and Ctrl+K links.
- Roadmap: the AI helpers on the privacy pages are dropped; 2.1.0 is the
  feedback page.
- Tests: tests/feedbackPage.test.ts, e2e/feedback.spec.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:03:05 +02:00
joshua 5cf982e191 Merge branch 'feat/billing-income-bands' 2026-11-03 12:18:01 +02:00
joshuaandClaude Opus 5.5 ff4c0c76d3 Billing income bands up to R10m, 12-month event income in the heartbeat (2.0.2)
- Heartbeat income bands widened from "over R100 000" to 14 bands up to
  "over R10 000 000"; every licence-tier edge is a band edge (tested).
- New income.eventIncome12m: last 12 months' event income without donations,
  sponsorships or donation legs, which the licence tier is billed on.
- Fix: donation legs were counted twice in the heartbeat income bands.
- Protocol doc, roadmap pricing decisions, changelog, what's new, handover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:18:01 +02:00
joshua 4f9268707d Merge branch 'fix/ai-summary-all-reports' 2026-11-02 18:42:27 +02:00
joshuaandClaude Opus 5.5 89eab66145 Summarise this report on every report (2.0.1)
The eight reports that list people get the AI summary too, sent as
anonymised totals built on the screen (aiReportData.ts): no names,
contact details, notes or comments; consent and medical only as counts.
The server refuses personal columns on those reports and logs each
summary in the access log (ai_report; sensitive for consent and medical).

Roadmap §18: 2.0.1, then crosscode.co.za, then 2.1.0 (AI helpers on the
Consent log and Access log).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:42:27 +02:00
joshua 75d10f6078 Merge branch 'docs/marketing-site' 2026-11-02 18:01:25 +02:00
joshuaandClaude Opus 5.5 8805101929 Docs: marketing site decisions (crosscode.co.za)
crosscode.co.za as Cross Code's main site with a Cross Events page;
church website and app as "coming later"; repo joshua/crosscode-site;
own server; no prices yet; contact form to info@crosscode.co.za;
privacy notice and terms of use; no cookies. Content from Joshua later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:01:23 +02:00
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
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 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
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
243 changed files with 9463 additions and 4814 deletions
+174
View File
@@ -7,6 +7,180 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [2.1.1] - 2026-11-03
### Fixed
- **Feedback "Send now" and "Remind" no longer make the page wait** for every email: they work out
who they're going to, answer straight away ("Sending the feedback form to 40 people in the
background", "Reminding 12 people. It carries on in the background.") and the emails go out in a
background task. The Feedback page refreshes itself a few times to show them land.
### Added
- **One-off background tasks** (`enqueueTask` in `src/jobs/index.js`, first one `feedback-send` →
`runFeedbackTask` in `utils/feedback.js`): queued in pg-boss (retried twice, kept across a
restart), or run in-process straight after the response when the queue isn't running. Tasks must
be safe to run twice: sending skips people already invited, and a reminder is claimed with the
24-hour rule, so two tasks at once never remind the same person twice.
- `POST /api/v2/events/{id}/feedback/remind` answers with `queued`; its counts are what's being
sent. `send-now`'s `invited` is how many it's sending to.
## [2.1.0] - 2026-11-03
### Added
- **Feedback page** (Supervisor → Feedback, `/dashboard/supervisor/feedback`; supervisors and
admins, campus-scoped supervisors see their campuses). Every event with a feedback form, newest
first: sent / scheduled / no send time, answered of asked with the response rate, the average
star rating and the last reminder. One event's page has the per-question **Results** (with the
AI "Summarise feedback" button), each **Response**, and on a named form **People** — who was
asked, who has answered and the reminders each has had. "Send now" before it goes out; "Edit
form" opens the event editor.
- **Remind those who haven't answered** (`POST /api/v2/events/{id}/feedback/remind`): a fresh link
to each, and the form to anyone eligible who hasn't had it yet (e.g. checked in after it went
out). On a named form one person can be reminded (`userIds`). Nobody gets the form or a reminder
twice within 24 hours (`REMIND_GAP_MS`). The automatic 3-day reminder is unchanged.
- `GET /api/v2/feedback` (every form) and `GET /api/v2/events/{id}/feedback/results` (one
event's results, responses and people) — `src/services/feedback.ts`, `src/v2/resources/feedback.ts`.
- `FeedbackInvite.reminderCount`, counting automatic and manual reminders (migration
`20261103120000_feedback_reminders`; existing reminded invites start at 1).
- Access log action **`feedback_responses`**: opening a named form's answers.
- Help page for the Feedback page; Feedback on the supervisor and admin dashboards and in Ctrl+K.
### Changed
- **Anonymous forms stay anonymous on the page:** no names on answers, and no list of who has or
hasn't answered (it would give answers away on a small event) — counts only, and no reminding
one person.
- A reminder now only goes to someone who still hasn't answered at the moment it's sent.
### Removed
- The planned 2.1.0 AI helpers on the privacy pages (Consent log, Access log) are dropped from
the roadmap.
## [2.0.2] - 2026-11-03
### Changed
- **Wider income bands in the Hub heartbeat**, from `R0–R1 000` up to `over R10 000 000` (was
`over R100 000`): 14 bands, edges at R1 000, R5 000, R20 000, R50 000, R100 000, R150 000,
R250 000, R400 000, R700 000, R1m, R2.5m, R5m and R10m (`BAND_TOPS` in `src/hub/report.js`).
Every Cross Code licence-tier edge is a band edge; a test checks it.
### Added
- **`income.eventIncome12m`** in the heartbeat: the band of the last 12 months' event income
(payments less refunds, without donations, sponsorships or donation legs), which the licence
tier is billed on. Shown on the Hub from Hub 1.6.2.
### Fixed
- The heartbeat's income bands counted a donation twice once it was applied to a registration
(the leg payment); they now leave legs out, like every report.
## [2.0.1] - 2026-11-02
### Changed
- **Summarise this report on every report.** The eight reports that list people (Payments between
dates, Attendees per event, Revenue detailed, Master orders, Cashup audit trail, Parental consent
status, Feedback summary, Medical & emergency list) now have the button too. They're sent as
**anonymised totals**, worked out on the screen (`components/reports/aiReportData.ts`): per event
and method, status or option, with names, emails, who recorded or did what, notes and comments
left out; consent and medical details only as counts (given / pending / declined, with
allergies, on medication, …); feedback as the per-question results without the comments.
`POST /api/v2/ai/summarise-report` accepts every report key; for a people report it refuses
(403 `AI_REPORT_HAS_PEOPLE`) any column that would carry a person (name, email, attendee,
parent, emergency contact, notes, …), so a new column fails safe. Each people-report summary is
recorded in the access log (`ai_report`; sensitive for consent and medical). The prompt tells
the AI the totals are anonymised on purpose.
## [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
+9 -2
View File
@@ -261,6 +261,13 @@ Feedback → Attendance**.
3 days later to anyone who hasn't answered.
- Answers can be anonymous or named. "Send now" is available, and the questions
lock once people have answered.
- **Feedback page** (Supervisor → Feedback, supervisors and admins): every event's form
with answered of asked, response rate, average rating and last reminder; per event the
results per question (and the AI summary), each response, and on named forms who was
asked and who has answered. **Remind** sends everyone who hasn't answered a fresh link
(and the form to anyone eligible who hasn't had it), or one person on a named form;
nobody gets the form or a reminder twice within 24 hours. Anonymous forms show no names
and no list of who has answered. Opening a named form's answers goes in the access log.
- **Sponsor a spot** — per-event setting. A "Sponsor a spot" button on the event page
lets anyone, including guests, pay for one or more spots (at the main ticket price)
or any amount through Yoco. They can give anonymously or add a name and a message.
@@ -903,8 +910,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
+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}` |
+7 -2
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
@@ -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)
+54
View File
@@ -12,6 +12,60 @@ 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.1.1
- **Sending feedback forms and reminders no longer makes you wait.** "Send now" and "Remind" on the
Feedback page now answer straight away and the emails go out in the background, so you can carry
on working. The page updates itself as they go out.
## 2.1.0
- **A Feedback page for supervisors and admins** (Dashboard → Feedback). See every event's feedback
in one place: how many people answered, the average star rating, each question's results and
every response. On a named form you can also see who was asked and who hasn't answered yet.
- **Remind the people who haven't answered.** One button sends everyone who hasn't given feedback
a fresh link by email or WhatsApp, or you can remind one person at a time on a named form.
Nobody is reminded twice within 24 hours. The automatic reminder after 3 days still goes out.
- **Anonymous forms stay anonymous:** the page never shows who gave which answer, or who has and
hasn't answered.
- Opening the answers to a named form is recorded in the access log.
## 2.0.2
- **Licence information sent to Cross Code.** Your site now tells Cross Code which range your event
income falls in over the last 12 months (for example "R150 001–R250 000"), which is what your
licence tier is based on. Donations and sponsorships aren't counted, and the exact amount never
leaves your site. The ranges now go up to over R10 million.
- Fixed: a donation that was applied to a registration was counted twice in the income range sent
to Cross Code.
## 2.0.1
- **Summarise this report on every report.** Reports that list people — payments, attendees,
revenue detailed, master orders, the cashup audit trail, parental consent, feedback and the
medical list — now have the AI summary button too. Only anonymised totals are sent (for example
"40 attendees, 6 with allergies"): no names, contact details, notes, comments or anyone's medical
details. Each of these summaries is recorded in the access log.
## 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
File diff suppressed because it is too large Load Diff
+235 -45
View File
@@ -1,15 +1,16 @@
{
"name": "event-management-backend",
"version": "1.52.3",
"version": "2.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.52.3",
"version": "2.1.1",
"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.52.3",
"version": "2.1.1",
"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,3 @@
-- 2.1.0: how many times each person has been reminded about a feedback form (automatic and by hand).
ALTER TABLE "FeedbackInvite" ADD COLUMN "reminderCount" INTEGER NOT NULL DEFAULT 0;
UPDATE "FeedbackInvite" SET "reminderCount" = 1 WHERE "remindedAt" IS NOT NULL;
+22 -1
View File
@@ -165,6 +165,7 @@ model User {
seriesFollows SeriesFollow[]
seasonPassPurchases SeasonPassPurchase[]
savedFilters SavedFilter[]
aiCalls AiCall[]
swapsAsked VolunteerSwap[] @relation("VolunteerSwapFrom")
swapsTaken VolunteerSwap[] @relation("VolunteerSwapTo")
@@ -833,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
@@ -1448,7 +1450,8 @@ model FeedbackInvite {
userId String
tokenHash String @unique
sentAt DateTime @default(now())
remindedAt DateTime?
remindedAt DateTime? // the last reminder (automatic or by hand)
reminderCount Int @default(0)
respondedAt DateTime?
form FeedbackForm @relation(fields: [formId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -2328,3 +2331,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));
+336
View File
@@ -0,0 +1,336 @@
/**
* 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: sent as they are on screen (keys from ReportCatalog). */
export const REPORTS_WITHOUT_PEOPLE = ['seriesComparison', 'regTypes', 'usage', 'revenue', 'regStatus', 'donations', 'funnel', 'discounts', 'cashup', 'financeReport', 'profitReport'] as const;
/**
* Reports that list people: the screen sends these as anonymised totals instead (no names, contact
* details, notes or comments; medical and consent only as counts). The server checks the columns too.
*/
export const REPORTS_WITH_PEOPLE = ['payments', 'attendees', 'revenueDetailed', 'masterOrders', 'cashupAudit', 'consentStatus', 'feedbackSummary', 'medicalList'] as const;
export const REPORT_KEYS = [...REPORTS_WITHOUT_PEOPLE, ...REPORTS_WITH_PEOPLE] as const;
/** Children's or special information: an AI summary of one is a sensitive access-log entry. */
export const SENSITIVE_REPORTS: readonly string[] = ['consentStatus', 'medicalList'];
/** Column headings that would carry a person: refused on a report with people in it. */
const PERSONAL_COLUMN = /^(user|name|e-?mail|phone|cell|attendee|donor)$|^(parent|guardian|emergency|performed by|recorded by|registration ?id|date of birth|dob|medical$|notes?$|comments?$|signed)/i;
const MAX_ROWS = 400;
/** The columns of a people report that would carry a person (none means it's safe to send). */
export function personalColumns(r: { reportKey: string; columns: string[] }): string[] {
if (!(REPORTS_WITH_PEOPLE as readonly string[]).includes(r.reportKey)) return [];
return r.columns.filter(c => PERSONAL_COLUMN.test(c.trim()));
}
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.',
'- Reports about people come as anonymised totals: names, contact details and notes are left out on purpose, so don\'t ask for them or guess who is who.',
'- 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 (!(REPORT_KEYS as readonly string[]).includes(report.reportKey)) throw new ApiError('VALIDATION_FAILED', `There is no report called ${report.reportKey}.`);
const personal = personalColumns(report);
if (personal.length) {
throw Object.assign(new ApiError('FORBIDDEN', `This report still has people in it (${personal.join(', ')}), so it can't be sent to the AI. Only anonymised totals are summarised.`), { 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 };
+8 -1374
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,
};
@@ -1,6 +1,6 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { ANSWERABLE, feedbackError, cleanFields, cleanAnswer, summarise, sendFeedbackInvites, findInviteByToken } = require('../utils/feedback');
const { ANSWERABLE, feedbackError, cleanFields, cleanAnswer, summarise, feedbackRecipients, findInviteByToken } = require('../utils/feedback');
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
const shape = (fields) => JSON.stringify(fields.map(f => [f.type, f.label, f.isRequired, f.options || null, f.helpText || null]));
@@ -86,9 +86,12 @@ const sendNow = async (req, res) => {
const form = await prisma.feedbackForm.findUnique({ where: { eventId: req.params.eventId } });
if (!form) throw feedbackError('This event has no feedback form.', 404);
if (!form.sentAt) await prisma.feedbackForm.update({ where: { id: form.id }, data: { sentAt: new Date(), sendAt: form.sendAt || new Date() } });
// Also invites anyone who checked in after the first send.
const invited = await sendFeedbackInvites(form.id);
res.json({ invited, message: invited ? `Feedback form sent to ${invited} ${invited === 1 ? 'person' : 'people'}.` : 'Everyone has already been sent the form.' });
// Also invites anyone who checked in after the first send. The emails go out in the
// background (2.1.1), so this answers straight away with how many it's going to.
const already = new Set((await prisma.feedbackInvite.findMany({ where: { formId: form.id }, select: { userId: true } })).map(i => i.userId));
const invited = (await feedbackRecipients(form)).filter(u => !already.has(u.id)).length;
if (invited) await require('../jobs').enqueueTask('feedback-send', { formId: form.id, kind: 'send' });
res.json({ invited, message: invited ? `Sending the feedback form to ${invited} ${invited === 1 ? 'person' : 'people'} in the background.` : 'Everyone has already been sent the form.' });
} catch (error) {
fail(res, error);
}
+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 };
+1 -31
View File
@@ -10,38 +10,8 @@
const { licenceStatus } = require('./licence');
// v1 paths (until 2.0 removes them) and their /api/v2 successors (1.52.3: the screens have used v2
// since 1.51.15, so without these a suspended or unlicensed site wasn't read-only any more).
// /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\/?$/],
// /api/v2 — the same actions.
['POST', /^\/api\/v2\/events\/?$/],
['PATCH', /^\/api\/v2\/events\/[^/]+\/?$/],
['POST', /^\/api\/v2\/events\/[^/]+\/copy-from\/?$/],
+28 -13
View File
@@ -13,21 +13,29 @@ const DAY = 24 * 60 * 60 * 1000;
const APP_VERSION = require('../../package.json').version;
const STARTED_AT = Date.now();
const INCOME_BANDS = [
[1000, 'R0–R1 000'],
[5000, 'R1 001–R5 000'],
[20000, 'R5 001–R20 000'],
[50000, 'R20 001–R50 000'],
[100000, 'R50 001–R100 000'],
];
// The tops of the income bands, in rand (docs/hub-protocol.md §3). Cross Code's licence tiers
// are billed on the 12-month event-income band, so every tier edge (R50 000, R150 000, R400 000,
// R1m, R2.5m, R5m, R10m) is also a band edge: change them together.
const BAND_TOPS = [1000, 5000, 20000, 50000, 100000, 150000, 250000, 400000, 700000, 1000000, 2500000, 5000000, 10000000];
/** The range an amount falls in: "R5 001–R20 000", or "over R100 000". */
const randText = n => `R${String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ' ')}`;
const INCOME_BANDS = BAND_TOPS.map((top, i) => [top, `${randText(i ? BAND_TOPS[i - 1] + 1 : 0)}–${randText(top)}`]);
const TOP_BAND = `over ${randText(BAND_TOPS[BAND_TOPS.length - 1])}`;
/** The range an amount falls in: "R5 001–R20 000", or "over R10 000 000". */
function incomeBand(amount) {
const rand = Math.max(0, Math.round(Number(amount) || 0));
for (const [max, label] of INCOME_BANDS) if (rand <= max) return label;
return 'over R100 000';
return TOP_BAND;
}
// Applying an earlier donation to a registration adds a "leg" payment that isn't new money
// (cashupUtils.isDonationLeg), so every money total leaves it out.
const NOT_DONATION_LEG = { NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] } };
// Event income, what the licence tier is billed on: payments less refunds, without donations or
// sponsorships (both isDonation) and without the legs that apply a donation to a registration.
const EVENT_INCOME = { isDonation: false, ...NOT_DONATION_LEG };
async function postgresVersion() {
try {
const rows = await prisma.$queryRawUnsafe('SHOW server_version');
@@ -98,8 +106,9 @@ async function health() {
async function stats(now = new Date()) {
const { campusesEnabled } = require('../utils/campuses');
const since = new Date(now.getTime() - 30 * DAY);
const yearAgo = new Date(now.getTime() - 365 * DAY);
const realUser = { isActive: true, NOT: [{ email: { endsWith: '@deleted.invalid' } }, { email: { endsWith: '@guest.local' } }] };
const [users, activeEvents, upcomingEvents, totalEvents, regs30, regsTotal, tickets30, ticketsTotal, checkIns30, income30, incomeAll, providers] = await Promise.all([
const [users, activeEvents, upcomingEvents, totalEvents, regs30, regsTotal, tickets30, ticketsTotal, checkIns30, income30, incomeAll, eventIncome12m, providers] = await Promise.all([
prisma.user.count({ where: realUser }),
prisma.event.count({ where: { isActive: true, startDate: { lte: now }, endDate: { gte: now } } }),
prisma.event.count({ where: { isActive: true, startDate: { gt: now } } }),
@@ -109,8 +118,9 @@ async function stats(now = new Date()) {
prisma.ticket.count({ where: { createdAt: { gte: since } } }),
prisma.ticket.count(),
prisma.ticketUsage.count({ where: { scannedAt: { gte: since } } }),
prisma.payment.aggregate({ where: { createdAt: { gte: since } }, _sum: { amount: true } }),
prisma.payment.aggregate({ _sum: { amount: true } }),
prisma.payment.aggregate({ where: { createdAt: { gte: since }, ...NOT_DONATION_LEG }, _sum: { amount: true } }),
prisma.payment.aggregate({ where: NOT_DONATION_LEG, _sum: { amount: true } }),
prisma.payment.aggregate({ where: { createdAt: { gte: yearAgo }, ...EVENT_INCOME }, _sum: { amount: true } }),
require('../payments').availableProviders().then(list => list.map(p => p.id)).catch(() => []),
]);
const campusesOn = await campusesEnabled().catch(() => false);
@@ -126,7 +136,12 @@ async function stats(now = new Date()) {
paymentProviders: providers,
campuses: campusesOn,
campusCount,
income: { last30d: incomeBand(income30._sum.amount), allTime: incomeBand(incomeAll._sum.amount) },
income: {
last30d: incomeBand(income30._sum.amount),
allTime: incomeBand(incomeAll._sum.amount),
// 2.0.2+: event income over the last 12 months, what the licence tier is billed on.
eventIncome12m: incomeBand(eventIncome12m._sum.amount),
},
};
}
+50 -1
View File
@@ -139,6 +139,38 @@ function isNoop(value) {
return false;
}
/**
* One-off background tasks (2.1.1): work a request starts but shouldn't wait for, like sending
* a feedback form to everyone. Queued in pg-boss (retried if it fails, kept across a restart);
* without the queue they run in this process straight after the response. A task must be safe
* to run twice.
*/
const TASKS = {
'feedback-send': (data) => require('../utils/feedback').runFeedbackTask(data),
};
async function runTask(name, data) {
const started = Date.now();
const result = await TASKS[name](data || {});
if (!isNoop(result)) console.log(`[jobs] task ${name} done in ${Date.now() - started} ms: ${summarize(result)}`);
return result ?? null;
}
/** Starts a task in the background. Resolves once it's queued (or started), not when it's done. */
async function enqueueTask(name, data) {
if (!TASKS[name]) throw new Error(`Unknown task ${name}`);
if (mode === 'queue' && boss) {
try {
await boss.send(name, data || {}, { retryLimit: 2, retryDelay: 30, expireInSeconds: 60 * 60 });
return { queued: true };
} catch (e) {
console.warn(`[jobs] could not queue ${name}, running it here:`, e?.message || e);
}
}
setImmediate(() => runTask(name, data).catch(e => console.warn(`[jobs] task ${name} failed:`, e?.message || e)));
return { queued: false };
}
async function runJob(job) {
const started = Date.now();
const result = await job.run();
@@ -218,6 +250,23 @@ async function startJobs() {
await boss.unschedule(job.name).catch(() => {});
}
}
// One-off tasks: any number can wait and run (unlike the stately scheduled jobs).
for (const name of Object.keys(TASKS)) {
if (!existing.has(name)) await boss.createQueue(name, { policy: 'standard', retryLimit: 2, retryDelay: 30, expireInSeconds: 60 * 60 });
await boss.work(name, async (batch) => {
const list = Array.isArray(batch) ? batch : [batch];
const out = [];
for (const job of list) {
try {
out.push(await runTask(name, job?.data));
} catch (e) {
console.warn(`[jobs] task ${name} failed:`, e?.message || e);
throw e; // recorded as failed, and retried
}
}
return out.length === 1 ? out[0] : out;
});
}
mode = 'queue';
const off = JOBS.filter(j => !isEnabled(j)).map(j => j.name);
console.log(`[jobs] Job queue started: ${enabled.map(j => j.name).join(', ')}${off.length ? ` (off: ${off.join(', ')})` : ''}.`);
@@ -298,4 +347,4 @@ async function jobStatus(prisma) {
};
}
module.exports = { JOBS, TIMEZONE, isEnabled, cronFor, nextRun, isNoop, startJobs, stopJobs, runNow, jobStatus };
module.exports = { JOBS, TASKS, TIMEZONE, isEnabled, cronFor, nextRun, isNoop, startJobs, stopJobs, runNow, jobStatus, enqueueTask };
-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', loginLimiter, 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;
@@ -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;
+4 -1
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 }>;
@@ -249,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))) {
+182
View File
@@ -0,0 +1,182 @@
/**
* The Feedback page (2.1.0): what supervisors and admins see of post-event feedback, and the
* "Remind" button. Forms, sending and the automatic reminder are in utils/feedback.js.
*
* Anonymous forms stay anonymous here: no names on answers, and no list of who has or hasn't
* answered (on a small event that would give the answers away) — only counts.
*/
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 fb = require('../utils/feedback') as {
ANSWERABLE: string[];
REMINDER_AFTER_MS: number;
REMIND_GAP_MS: number;
summarise(fields: any[], answers: any[]): any[];
feedbackRecipients(form: any): Promise<{ id: string }[]>;
sendFeedbackInvites(formId: string): Promise<number>;
sendFeedbackReminders(formId: string, opts?: { userIds?: string[] | null; minGapMs?: number | null; now?: Date }): Promise<number>;
};
const jobs = require('../jobs') as { enqueueTask(name: string, data: object): Promise<{ queued: boolean }> };
export type FormStatus = 'sent' | 'scheduled' | 'not_scheduled';
const statusOf = (form: { sentAt: Date | null; sendAt: Date | null }): FormStatus => (form.sentAt ? 'sent' : form.sendAt ? 'scheduled' : 'not_scheduled');
const rate = (answered: number, invited: number) => (invited ? Math.round((answered / invited) * 100) : null);
const round1 = (n: number) => Math.round(n * 10) / 10;
/** Average of every star rating on a form (all rating questions together), or null. Pure. */
export function averageRating(values: string[]): number | null {
const nums = values.map(Number).filter(n => Number.isInteger(n) && n >= 1 && n <= 5);
return nums.length ? round1(nums.reduce((s, n) => s + n, 0) / nums.length) : null;
}
/** Every event with a feedback form, newest event first. `where` is the campus scope (listWhere). */
export async function feedbackOverview(where: object = {}) {
const forms = await prisma.feedbackForm.findMany({
where,
include: {
event: { select: { id: true, title: true, startDate: true, endDate: true } },
_count: { select: { invites: true, responses: true } },
},
});
if (!forms.length) return [];
const ids = forms.map((f: any) => f.id);
const [ratings, reminders] = await Promise.all([
prisma.feedbackAnswer.findMany({ where: { field: { formId: { in: ids }, type: 'rating' } }, select: { value: true, field: { select: { formId: true } } } }),
prisma.feedbackInvite.groupBy({ by: ['formId'], where: { formId: { in: ids } }, _max: { remindedAt: true } }),
]);
const ratingsBy = new Map<string, string[]>();
for (const r of ratings) ratingsBy.set(r.field.formId, [...(ratingsBy.get(r.field.formId) || []), r.value]);
const remindedBy = new Map(reminders.map((r: any) => [r.formId, r._max.remindedAt]));
return forms
.map((f: any) => ({
event: f.event,
status: statusOf(f),
sendAt: f.sendAt,
sentAt: f.sentAt,
anonymous: f.anonymous,
invited: f._count.invites,
answered: f._count.responses,
responseRate: rate(f._count.responses, f._count.invites),
averageRating: averageRating(ratingsBy.get(f.id) || []),
lastReminderAt: remindedBy.get(f.id) || null,
}))
.sort((a: any, b: any) => new Date(b.event.startDate).getTime() - new Date(a.event.startDate).getTime());
}
async function formFor(eventId: string, include: object = {}) {
const form = await prisma.feedbackForm.findUnique({ where: { eventId }, include: { event: { select: { id: true, title: true, startDate: true, endDate: true } }, ...include } });
if (!form) fail(404, 'This event has no feedback form.', 'NO_FEEDBACK_FORM');
return form;
}
/** People who would get the form but haven't been sent it (not sent yet, or checked in later). */
async function waitingCount(form: any, invitedIds: Set<string>) {
return (await fb.feedbackRecipients(form)).filter(u => !invitedIds.has(u.id)).length;
}
/** One event's feedback: results per question, each response, and (named forms) who was asked. */
export async function eventFeedback(eventId: string, now = new Date()) {
const form = await formFor(eventId, {
fields: { orderBy: { order: 'asc' } },
responses: { orderBy: { createdAt: 'desc' }, include: { answers: true, user: { select: { name: true } } } },
invites: { include: { user: { select: { id: true, name: true, email: true, phoneNumber: true } } } },
});
const named = !form.anonymous;
const fields = form.fields.filter((f: any) => fb.ANSWERABLE.includes(f.type));
const answers = form.responses.flatMap((r: any) => r.answers.map((a: any) => ({ fieldId: a.fieldId, value: a.value, ...(named ? { name: r.user?.name || '' } : {}) })));
const invited = form.invites.length;
const answered = form.responses.length;
const gapFrom = now.getTime() - fb.REMIND_GAP_MS;
const notAnswered = form.invites.filter((i: any) => !i.respondedAt);
// Not sent the form or a reminder in the last 24 hours (as remindFeedback).
const remindable = (i: any) => !i.respondedAt && new Date(i.sentAt).getTime() < gapFrom && (!i.remindedAt || new Date(i.remindedAt).getTime() < gapFrom);
const canRemind = notAnswered.filter(remindable).length;
const waiting = await waitingCount(form, new Set(form.invites.map((i: any) => i.userId)));
const autoReminderAt = form.sentAt ? new Date(new Date(form.sentAt).getTime() + fb.REMINDER_AFTER_MS) : null;
return {
event: form.event,
form: {
status: statusOf(form),
sendAt: form.sendAt,
sentAt: form.sentAt,
audience: (form.audience === 'all' ? 'all' : 'checked_in') as 'all' | 'checked_in',
anonymous: form.anonymous,
intro: form.intro,
autoReminderAt,
autoReminderSent: !!form.reminderSentAt,
},
counts: {
invited,
answered,
notAnswered: notAnswered.length,
responseRate: rate(answered, invited),
canRemind,
remindedRecently: notAnswered.length - canRemind,
waiting,
},
averageRating: averageRating(answers.filter((a: any) => fields.some((f: any) => f.id === a.fieldId && f.type === 'rating')).map((a: any) => a.value)),
questions: fb.summarise(fields, answers),
fields: fields.map((f: any) => ({ id: f.id, type: f.type, label: f.label })),
responses: form.responses.map((r: any) => ({
id: r.id,
createdAt: r.createdAt,
name: named ? r.user?.name || 'Someone who has since been removed' : null,
answers: Object.fromEntries(r.answers.map((a: any) => [a.fieldId, a.value])),
})),
people: named
? form.invites
.map((i: any) => ({
userId: i.userId,
name: i.user?.name || '',
email: i.user?.email && !/@(guest\.local|deleted\.invalid)$/.test(i.user.email) ? i.user.email : null,
phoneNumber: i.user?.phoneNumber || null,
sentAt: i.sentAt,
remindedAt: i.remindedAt,
reminderCount: i.reminderCount,
respondedAt: i.respondedAt,
canRemind: remindable(i),
}))
.sort((a: any, b: any) => Number(!!a.respondedAt) - Number(!!b.respondedAt) || a.name.localeCompare(b.name))
: null,
};
}
/**
* "Remind": a fresh link to everyone who hasn't answered (or the people in `userIds`, named
* forms only), skipping anyone sent the form or a reminder in the last 24 hours. Without `userIds` it also sends
* the form to people who became eligible after it went out (e.g. checked in later).
*
* 2.1.1: the emails go out in the background (jobs task `feedback-send`); this works out who
* they'll go to and answers straight away. The counts are what's being sent, not what was.
*/
export async function remindFeedback(eventId: string, { userIds }: { userIds?: string[] | null } = {}, now = new Date()) {
const form = await formFor(eventId);
if (!form.sentAt) fail(409, 'The feedback form hasn\'t gone out yet. Use "Send now" to send it.', 'FEEDBACK_NOT_SENT');
const only = userIds && userIds.length ? [...new Set(userIds)] : null;
if (only && form.anonymous) fail(400, 'This form is anonymous, so people can\'t be reminded one by one. Remind everyone who hasn\'t answered instead.', 'FEEDBACK_ANONYMOUS');
const since = new Date(now.getTime() - fb.REMIND_GAP_MS);
const mine = { formId: form.id, respondedAt: null, ...(only ? { userId: { in: only } } : {}) };
const [notAnswered, reminded, invitedIds] = await Promise.all([
prisma.feedbackInvite.count({ where: mine }),
// The same rule as sendFeedbackReminders with minGapMs.
prisma.feedbackInvite.count({ where: { ...mine, sentAt: { lt: since }, OR: [{ remindedAt: null }, { remindedAt: { lt: since } }] } }),
only ? Promise.resolve(null) : prisma.feedbackInvite.findMany({ where: { formId: form.id }, select: { userId: true } }),
]);
const newlyInvited = invitedIds ? await waitingCount(form, new Set(invitedIds.map((i: any) => i.userId))) : 0;
const skipped = Math.max(0, notAnswered - reminded);
if (reminded || newlyInvited) await jobs.enqueueTask('feedback-send', { formId: form.id, kind: 'remind', userIds: only });
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
const parts: string[] = [];
if (reminded) parts.push(`Reminding ${plural(reminded, 'person', 'people')}`);
if (newlyInvited) parts.push(`sending the form to ${plural(newlyInvited, 'person', 'people')} for the first time`);
let message = parts.length ? `${parts.join(' and ')}. It carries on in the background.` : 'Nobody to remind.';
if (!parts.length && skipped) message = 'Everyone who hasn\'t answered was sent the form or a reminder in the last 24 hours. Try again tomorrow.';
else if (parts.length && skipped) message += ` ${plural(skipped, 'person was', 'people were')} sent the form or a reminder in the last 24 hours, so not again yet.`;
message = message.charAt(0).toUpperCase() + message.slice(1);
return { reminded, newlyInvited, skipped, queued: !!(reminded || newlyInvited), message, title: form.event.title };
}
+3
View File
@@ -18,6 +18,9 @@ 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',
feedback_responses: 'Feedback answers viewed (named form)',
ai_report: 'Report summarised by the AI helper (anonymised totals)',
};
/**
+41 -7
View File
@@ -183,19 +183,53 @@ async function sendFeedbackInvites(formId) {
return people.length;
}
/** The single reminder: a fresh link for everyone who hasn't answered. */
async function sendFeedbackReminders(formId) {
/** A manual reminder isn't sent to the same person again within this long. */
const REMIND_GAP_MS = 24 * 3600e3;
/**
* A fresh link for everyone who hasn't answered. With no options it's the automatic reminder:
* only people never reminded. `userIds` limits it to those people; `minGapMs` skips anyone
* reminded more recently than that (the manual "Remind" button). Returns how many were sent.
*/
async function sendFeedbackReminders(formId, { userIds = null, minGapMs = null, now = new Date() } = {}) {
const form = await prisma.feedbackForm.findUnique({ where: { id: formId }, include: { event: true } });
if (!form) return 0;
// Manual: not if they were sent the form, or reminded, within minGapMs.
const since = minGapMs == null ? null : new Date(now.getTime() - minGapMs);
const recent = since == null ? { remindedAt: null } : { sentAt: { lt: since }, OR: [{ remindedAt: null }, { remindedAt: { lt: since } }] };
const invites = await prisma.feedbackInvite.findMany({
where: { formId, respondedAt: null, remindedAt: null },
where: { formId, respondedAt: null, ...recent, ...(userIds ? { userId: { in: userIds } } : {}) },
include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } },
});
let sent = 0;
for (const inv of invites) {
const token = newToken();
await prisma.feedbackInvite.update({ where: { id: inv.id }, data: { tokenHash: hashToken(token), remindedAt: new Date() } });
// Only if they still haven't answered (they may have while this ran).
const claimed = await prisma.feedbackInvite.updateMany({
// The same rule again, so two tasks running at once never remind the same person twice.
where: { id: inv.id, respondedAt: null, ...recent },
data: { tokenHash: hashToken(token), remindedAt: now, reminderCount: { increment: 1 } },
});
if (claimed.count !== 1) continue;
sent += 1;
await sendFeedbackMessage(inv.user, form.event, token, { reminder: true }).catch(e => console.warn('[feedback] reminder failed:', e?.message || e));
}
return invites.length;
return sent;
}
/**
* The background task behind "Send now" and "Remind" (jobs `feedback-send`), so the page doesn't
* wait for every email. Safe to run twice: people already invited are skipped, and nobody is
* reminded twice within REMIND_GAP_MS.
* { formId, kind: 'send' } the form to everyone eligible not yet invited
* { formId, kind: 'remind', userIds? } (everyone: also invite the newly eligible) then remind
*/
async function runFeedbackTask({ formId, kind, userIds = null } = {}) {
if (!formId) return { invited: 0, reminded: 0 };
const only = Array.isArray(userIds) && userIds.length ? userIds : null;
const invited = kind === 'send' || (kind === 'remind' && !only) ? await sendFeedbackInvites(formId) : 0;
const reminded = kind === 'remind' ? await sendFeedbackReminders(formId, { userIds: only, minGapMs: REMIND_GAP_MS }) : 0;
return { invited, reminded };
}
/** Background job: send forms whose time has come, and reminders 3 days after. */
@@ -224,7 +258,7 @@ async function findInviteByToken(token) {
}
module.exports = {
ANSWERABLE, ALL_TYPES, REMINDER_AFTER_MS, feedbackError, hashToken, feedbackLink,
ANSWERABLE, ALL_TYPES, REMINDER_AFTER_MS, REMIND_GAP_MS, feedbackError, hashToken, feedbackLink,
cleanFields, cleanAnswer, summarise,
feedbackRecipients, sendFeedbackInvites, sendFeedbackReminders, runFeedbackJob, findInviteByToken,
feedbackRecipients, sendFeedbackInvites, sendFeedbackReminders, runFeedbackTask, runFeedbackJob, findInviteByToken,
};
+1 -1
View File
@@ -18,7 +18,7 @@ const prisma = require('../config/db');
const MAX_MINUTES = 30;
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
// v1 and v2 paths for ending a viewing-as session and logging what was viewed.
const ALLOWED_WRITES = ['/api/admin/impersonate/end', '/api/admin/impersonate/log', '/api/v2/me/impersonation/end', '/api/v2/me/impersonation/log'];
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) {
+2
View File
@@ -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';
-380
View File
@@ -1,380 +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.16: incoming webhooks and links in sent messages (v1 still answered by the same code)
{ method: 'POST', v1: /^\/api\/webhooks\/(yoco|payfast|whatsapp)$/, v2: m => `/api/v2/webhooks/${m[1]}` },
{ method: 'POST', v1: /^\/api\/whatsapp\/webhook$/, v2: () => '/api/v2/webhooks/whatsapp' },
{ method: 'POST', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)\/one-click$/, v2: m => `/api/v2/unsubscribe/${m[1]}/one-click` },
// 1.51.15: privacy and admin
{ method: 'GET', v1: /^\/api\/privacy\/me$/, v2: () => '/api/v2/me/privacy' },
{ method: 'PUT', v1: /^\/api\/privacy\/me\/news$/, v2: () => '/api/v2/me/privacy/news' },
{ method: 'GET', v1: /^\/api\/privacy\/me\/export$/, v2: () => '/api/v2/me/privacy/export' },
{ method: 'POST', v1: /^\/api\/privacy\/me\/requests$/, v2: () => '/api/v2/me/privacy/requests' },
{ method: 'POST', v1: /^\/api\/privacy\/me\/notice\/accept$/, v2: () => '/api/v2/me/privacy/notice/accept' },
{ method: 'GET', v1: /^\/api\/privacy\/notice$/, v2: () => '/api/v2/privacy/notice' },
{ method: 'GET', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)$/, v2: m => `/api/v2/unsubscribe/${m[1]}` },
{ method: 'POST', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)$/, v2: m => `/api/v2/unsubscribe/${m[1]}` },
{ method: 'GET', v1: /^\/api\/privacy\/requests$/, v2: () => '/api/v2/privacy/requests' },
{ method: 'POST', v1: /^\/api\/privacy\/requests\/([^/]+)\/(complete|reject)$/, v2: m => `/api/v2/privacy/requests/${m[1]}/${m[2]}` },
{ method: 'GET', v1: /^\/api\/privacy\/notice\/draft$/, v2: () => '/api/v2/privacy/notice/draft' },
{ method: 'PUT', v1: /^\/api\/privacy\/notice\/draft$/, v2: () => '/api/v2/privacy/notice/draft' },
{ method: 'DELETE', v1: /^\/api\/privacy\/notice\/draft$/, v2: () => '/api/v2/privacy/notice/draft' },
{ method: 'POST', v1: /^\/api\/privacy\/notice\/(publish|builtin-reviewed)$/, v2: m => `/api/v2/privacy/notice/${m[1]}` },
{ method: 'GET', v1: /^\/api\/privacy\/operators$/, v2: () => '/api/v2/privacy/operators' },
{ method: 'POST', v1: /^\/api\/privacy\/operators$/, v2: () => '/api/v2/privacy/operators' },
{ method: 'PUT', v1: /^\/api\/privacy\/operators\/([^/]+)$/, v2: m => `/api/v2/privacy/operators/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/privacy\/operators\/([^/]+)$/, v2: m => `/api/v2/privacy/operators/${m[1]}` },
{ method: 'GET', v1: /^\/api\/privacy\/(consent-log|access-log|readiness|retention|breaches)$/, v2: m => `/api/v2/privacy/${m[1]}` },
{ method: 'PUT', v1: /^\/api\/privacy\/retention$/, v2: () => '/api/v2/privacy/retention' },
{ method: 'POST', v1: /^\/api\/privacy\/retention\/run$/, v2: () => '/api/v2/privacy/retention/run' },
{ method: 'POST', v1: /^\/api\/privacy\/breaches$/, v2: () => '/api/v2/privacy/breaches' },
{ method: 'PUT', v1: /^\/api\/privacy\/breaches\/([^/]+)$/, v2: m => `/api/v2/privacy/breaches/${m[1]}` },
{ method: 'GET', v1: /^\/api\/admin\/(audit-log|jobs|impersonation-sessions)$/, v2: m => `/api/v2/admin/${m[1]}` },
{ method: 'POST', v1: /^\/api\/admin\/jobs\/([^/]+)\/run$/, v2: m => `/api/v2/admin/jobs/${m[1]}/run` },
{ method: 'POST', v1: /^\/api\/admin\/impersonate\/(end|log)$/, v2: m => `/api/v2/me/impersonation/${m[1]}` },
{ method: 'GET', v1: /^\/api\/admin\/impersonate\/current$/, v2: () => '/api/v2/me/impersonation' },
{ method: 'POST', v1: /^\/api\/admin\/impersonate\/([^/]+)$/, v2: m => `/api/v2/admin/impersonate/${m[1]}` },
{ method: 'GET', v1: /^\/api\/settings\/needs-setup$/, v2: () => '/api/v2/setup/needed' },
{ method: 'GET', v1: /^\/api\/settings\/all$/, v2: () => '/api/v2/settings/all' },
{ method: 'GET', v1: /^\/api\/settings\/?$/, v2: () => '/api/v2/settings' },
{ method: 'PUT', v1: /^\/api\/settings\/?$/, v2: () => '/api/v2/settings' },
{ method: 'POST', v1: /^\/api\/settings\/test-smtp$/, v2: () => '/api/v2/settings/test-smtp' },
{ method: 'POST', v1: /^\/api\/setup\/register$/, v2: () => '/api/v2/setup/register' },
{ method: 'POST', v1: /^\/api\/setup\/?$/, v2: () => '/api/v2/setup' },
{ method: 'GET', v1: /^\/api\/hub\/(status|notices)$/, v2: m => `/api/v2/hub/${m[1]}` },
{ method: 'POST', v1: /^\/api\/hub\/(connect|heartbeat|update-request|update-snooze)$/, v2: m => `/api/v2/hub/${m[1]}` },
{ method: 'POST', v1: /^\/api\/hub\/update-request\/cancel$/, v2: () => '/api/v2/hub/update-request/cancel' },
{ method: 'GET', v1: /^\/api\/outbound-webhooks\/?$/, v2: () => '/api/v2/outbound-webhooks' },
{ method: 'POST', v1: /^\/api\/outbound-webhooks\/?$/, v2: () => '/api/v2/outbound-webhooks' },
{ method: 'POST', v1: /^\/api\/outbound-webhooks\/deliveries\/([^/]+)\/resend$/, v2: m => `/api/v2/outbound-webhook-deliveries/${m[1]}/resend` },
{ method: 'PUT', v1: /^\/api\/outbound-webhooks\/([^/]+)$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/outbound-webhooks\/([^/]+)$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}` },
{ method: 'POST', v1: /^\/api\/outbound-webhooks\/([^/]+)\/(rotate-secret|test)$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}/${m[2]}` },
{ method: 'GET', v1: /^\/api\/outbound-webhooks\/([^/]+)\/deliveries$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}/deliveries` },
{ method: 'GET', v1: /^\/api\/backups\/?$/, v2: () => '/api/v2/backups' },
{ method: 'POST', v1: /^\/api\/backups\/run$/, v2: () => '/api/v2/backups/run' },
{ method: 'GET', v1: /^\/api\/backups\/([^/]+)\/download$/, v2: m => `/api/v2/backups/${m[1]}/download` },
{ method: 'GET', v1: /^\/api\/(yoco|payfast)\/config$/, v2: m => `/api/v2/payments/${m[1]}/config` },
{ method: 'PUT', v1: /^\/api\/(yoco|payfast)\/config$/, v2: m => `/api/v2/payments/${m[1]}/config` },
{ method: 'GET', v1: /^\/api\/yoco\/webhooks$/, v2: () => '/api/v2/payments/yoco/webhooks' },
{ method: 'POST', v1: /^\/api\/yoco\/webhooks$/, v2: () => '/api/v2/payments/yoco/webhooks' },
{ method: 'DELETE', v1: /^\/api\/yoco\/webhooks\/([^/]+)$/, v2: m => `/api/v2/payments/yoco/webhooks/${m[1]}` },
{ method: 'POST', v1: /^\/api\/payfast\/test$/, v2: () => '/api/v2/payments/payfast/test' },
// 1.51.14: people and messaging
{ method: 'POST', v1: /^\/api\/users\/login\/2fa\/resend$/, v2: () => '/api/v2/auth/login/code/resend' },
{ method: 'POST', v1: /^\/api\/users\/forgot$/, v2: () => '/api/v2/auth/password/forgot' },
{ method: 'POST', v1: /^\/api\/users\/reset$/, v2: () => '/api/v2/auth/password/reset' },
{ method: 'POST', v1: /^\/api\/users\/activate$/, v2: () => '/api/v2/auth/activate' },
{ method: 'POST', v1: /^\/api\/users\/revoke-sessions$/, v2: () => '/api/v2/me/sessions/revoke' },
{ method: 'POST', v1: /^\/api\/users\/close-account$/, v2: () => '/api/v2/me/close' },
{ method: 'GET', v1: /^\/api\/users\/activity$/, v2: () => '/api/v2/me/activity' },
{ method: 'POST', v1: /^\/api\/users\/verify-password$/, v2: () => '/api/v2/me/verify-password' },
{ method: 'POST', v1: /^\/api\/users\/2fa\/(enable|confirm|disable)$/, v2: m => `/api/v2/me/two-factor/${m[1]}` },
{ method: 'GET', v1: /^\/api\/users\/check-exists$/, v2: () => '/api/v2/users/exists' },
{ method: 'POST', v1: /^\/api\/users\/([^/]+)\/revoke-sessions$/, v2: m => `/api/v2/users/${m[1]}/sessions/revoke` },
{ method: 'POST', v1: /^\/api\/users\/([^/]+)\/anonymize$/, v2: m => `/api/v2/users/${m[1]}/anonymize` },
{ method: 'GET', v1: /^\/api\/users\/?$/, v2: () => '/api/v2/users' },
// (/api/users/profile is /api/v2/me — see 1.51.2 below.)
{ method: 'GET', v1: /^\/api\/users\/(?!profile$)([^/]+)$/, v2: m => `/api/v2/users/${m[1]}` },
{ method: 'PUT', v1: /^\/api\/users\/(?!profile$)([^/]+)$/, v2: m => `/api/v2/users/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/users\/([^/]+)$/, v2: m => `/api/v2/users/${m[1]}` },
{ method: 'GET', v1: /^\/api\/events\/admin\/all$/, v2: () => '/api/v2/admin/events' },
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/(email|whatsapp)-attendees$/, v2: m => `/api/v2/events/${m[1]}/${m[2]}-attendees` },
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/(email|whatsapp)-attendees\/schedule$/, v2: m => `/api/v2/events/${m[1]}/${m[2]}-attendees/schedule` },
{ method: 'POST', v1: /^\/api\/broadcasts\/(preview|send|schedule)$/, v2: m => `/api/v2/broadcasts/email/${m[1]}` },
{ method: 'POST', v1: /^\/api\/whatsapp-broadcasts\/(preview|send|schedule)$/, v2: m => `/api/v2/broadcasts/whatsapp/${m[1]}` },
{ method: 'GET', v1: /^\/api\/scheduled-emails\/?$/, v2: () => '/api/v2/scheduled-messages' },
{ method: 'PATCH', v1: /^\/api\/scheduled-emails\/([^/]+)$/, v2: m => `/api/v2/scheduled-messages/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/scheduled-emails\/([^/]+)$/, v2: m => `/api/v2/scheduled-messages/${m[1]}` },
{ method: 'POST', v1: /^\/api\/automations\/schedule$/, v2: () => '/api/v2/automations/schedule' },
{ method: 'GET', v1: /^\/api\/whatsapp\/(config|status|qr)$/, v2: m => `/api/v2/whatsapp/${m[1]}` },
{ method: 'POST', v1: /^\/api\/whatsapp\/config$/, v2: () => '/api/v2/whatsapp/config' },
{ method: 'POST', v1: /^\/api\/whatsapp\/create-instance$/, v2: () => '/api/v2/whatsapp/instance' },
{ method: 'POST', v1: /^\/api\/whatsapp\/delete-instance$/, v2: () => '/api/v2/whatsapp/instance' },
{ method: 'POST', v1: /^\/api\/whatsapp\/(request-code|logout|start|restart)$/, v2: m => `/api/v2/whatsapp/${m[1]}` },
{ method: 'GET', v1: /^\/api\/banner\/?$/, v2: () => '/api/v2/banner' },
{ method: 'POST', v1: /^\/api\/banner\/?$/, v2: () => '/api/v2/banner' },
{ method: 'GET', v1: /^\/api\/saved-filters\/?$/, v2: () => '/api/v2/saved-filters' },
{ method: 'POST', v1: /^\/api\/saved-filters\/?$/, v2: () => '/api/v2/saved-filters' },
{ method: 'PUT', v1: /^\/api\/saved-filters\/([^/]+)$/, v2: m => `/api/v2/saved-filters/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/saved-filters\/([^/]+)$/, v2: m => `/api/v2/saved-filters/${m[1]}` },
{ method: 'GET', v1: /^\/api\/dashboard\/(today|search)$/, v2: m => `/api/v2/dashboard/${m[1]}` },
{ method: 'GET', v1: /^\/api\/stats\/(staff|supervisor|admin|overview)$/, v2: m => `/api/v2/stats/${m[1]}` },
{ method: 'POST', v1: /^\/api\/analytics\/track$/, v2: () => '/api/v2/analytics/track' },
{ method: 'GET', v1: /^\/api\/analytics\/funnel$/, v2: () => '/api/v2/analytics/funnel' },
{ method: 'POST', v1: /^\/api\/reports\/(pdf|excel|email|whatsapp)$/, v2: m => `/api/v2/reports/${m[1]}` },
// 1.51.13: kids check-in and volunteer rosters
{ method: 'GET', v1: /^\/api\/kids\/me$/, v2: () => '/api/v2/me/family' },
{ method: 'POST', v1: /^\/api\/kids\/me$/, v2: () => '/api/v2/me/family' },
{ method: 'PUT', v1: /^\/api\/kids\/me$/, v2: () => '/api/v2/me/family' },
{ method: 'DELETE', v1: /^\/api\/kids\/me$/, v2: () => '/api/v2/me/family' },
{ method: 'POST', v1: /^\/api\/kids\/me\/(children|adults)$/, v2: m => `/api/v2/me/family/${m[1]}` },
{ method: 'PUT', v1: /^\/api\/kids\/me\/(children|adults)\/([^/]+)$/, v2: m => `/api/v2/me/family/${m[1]}/${m[2]}` },
{ method: 'DELETE', v1: /^\/api\/kids\/me\/(children|adults)\/([^/]+)$/, v2: m => `/api/v2/me/family/${m[1]}/${m[2]}` },
{ method: 'GET', v1: /^\/api\/kids\/setup-events$/, v2: () => '/api/v2/kids/setup-events' },
{ method: 'GET', v1: /^\/api\/kids\/events$/, v2: () => '/api/v2/kids/events' },
{ method: 'GET', v1: /^\/api\/kids\/events\/([^/]+)\/(settings|board|families|offline)$/, v2: m => `/api/v2/events/${m[1]}/kids/${m[2]}` },
{ method: 'PUT', v1: /^\/api\/kids\/events\/([^/]+)\/settings$/, v2: m => `/api/v2/events/${m[1]}/kids/settings` },
{ method: 'POST', v1: /^\/api\/kids\/events\/([^/]+)\/(families|check-in|check-out|duty|sync)$/, v2: m => `/api/v2/events/${m[1]}/kids/${m[2]}` },
{ method: 'GET', v1: /^\/api\/kids\/events\/([^/]+)\/families\/qr\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/kids/families/qr/${m[2]}` },
{ method: 'GET', v1: /^\/api\/kids\/events\/([^/]+)\/families\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/kids/families/${m[2]}` },
{ method: 'POST', v1: /^\/api\/kids\/events\/([^/]+)\/families\/([^/]+)\/(parents|children|adults)$/, v2: m => `/api/v2/events/${m[1]}/kids/families/${m[2]}/${m[3]}` },
{ method: 'PUT', v1: /^\/api\/kids\/events\/([^/]+)\/families\/([^/]+)\/children\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/kids/families/${m[2]}/children/${m[3]}` },
{ method: 'GET', v1: /^\/api\/kids\/events\/([^/]+)\/code\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/kids/code/${m[2]}` },
{ method: 'POST', v1: /^\/api\/kids\/check-ins\/([^/]+)\/(override|page)$/, v2: m => `/api/v2/kids/check-ins/${m[1]}/${m[2]}` },
{ method: 'PUT', v1: /^\/api\/kids\/check-ins\/([^/]+)\/room$/, v2: m => `/api/v2/kids/check-ins/${m[1]}/room` },
{ method: 'POST', v1: /^\/api\/kids\/alerts\/([^/]+)\/resolve$/, v2: m => `/api/v2/kids/alerts/${m[1]}/resolve` },
{ method: 'GET', v1: /^\/api\/volunteers\/me$/, v2: () => '/api/v2/me/volunteering' },
{ method: 'GET', v1: /^\/api\/volunteers\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/volunteer-roles` },
{ method: 'POST', v1: /^\/api\/volunteers\/roles\/([^/]+)\/sign-up$/, v2: m => `/api/v2/volunteer-roles/${m[1]}/sign-up` },
{ method: 'DELETE', v1: /^\/api\/volunteers\/slots\/([^/]+)$/, v2: m => `/api/v2/volunteer-slots/${m[1]}` },
{ method: 'POST', v1: /^\/api\/volunteers\/slots\/([^/]+)\/swap$/, v2: m => `/api/v2/volunteer-slots/${m[1]}/swap` },
{ method: 'DELETE', v1: /^\/api\/volunteers\/swaps\/([^/]+)$/, v2: m => `/api/v2/volunteer-swaps/${m[1]}` },
{ method: 'POST', v1: /^\/api\/volunteers\/swaps\/([^/]+)\/accept$/, v2: m => `/api/v2/volunteer-swaps/${m[1]}/accept` },
{ method: 'POST', v1: /^\/api\/volunteers\/check-in$/, v2: () => '/api/v2/volunteering/check-in' },
{ method: 'GET', v1: /^\/api\/volunteers\/manage\/events$/, v2: () => '/api/v2/volunteering/events' },
{ method: 'GET', v1: /^\/api\/volunteers\/manage\/hours$/, v2: () => '/api/v2/volunteering/hours' },
{ method: 'GET', v1: /^\/api\/volunteers\/manage\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/volunteers` },
{ method: 'GET', v1: /^\/api\/volunteers\/manage\/events\/([^/]+)\/(qr|settings)$/, v2: m => `/api/v2/events/${m[1]}/volunteers/${m[2]}` },
{ method: 'PUT', v1: /^\/api\/volunteers\/manage\/events\/([^/]+)\/settings$/, v2: m => `/api/v2/events/${m[1]}/volunteers/settings` },
{ method: 'POST', v1: /^\/api\/volunteers\/manage\/events\/([^/]+)\/roles$/, v2: m => `/api/v2/events/${m[1]}/volunteer-roles` },
{ method: 'PUT', v1: /^\/api\/volunteers\/manage\/roles\/([^/]+)$/, v2: m => `/api/v2/volunteer-roles/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/volunteers\/manage\/roles\/([^/]+)$/, v2: m => `/api/v2/volunteer-roles/${m[1]}` },
{ method: 'POST', v1: /^\/api\/volunteers\/manage\/roles\/([^/]+)\/assign$/, v2: m => `/api/v2/volunteer-roles/${m[1]}/assign` },
{ method: 'POST', v1: /^\/api\/volunteers\/manage\/slots\/([^/]+)\/check-in$/, v2: m => `/api/v2/volunteer-slots/${m[1]}/check-in` },
{ method: 'DELETE', v1: /^\/api\/volunteers\/manage\/slots\/([^/]+)$/, v2: m => `/api/v2/volunteer-slots/${m[1]}/remove` },
// 1.51.12: tickets, scan locks, attendance and certificates, allocations
{ method: 'GET', v1: /^\/api\/tickets\/mytickets$/, v2: () => '/api/v2/me/tickets' },
{ method: 'POST', v1: /^\/api\/tickets\/email$/, v2: () => '/api/v2/me/tickets/email' },
{ method: 'GET', v1: /^\/api\/tickets\/scans\/recent$/, v2: () => '/api/v2/tickets/scans/recent' },
{ method: 'GET', v1: /^\/api\/tickets\/scans\/stats$/, v2: () => '/api/v2/tickets/scans/stats' },
{ method: 'POST', v1: /^\/api\/tickets\/send-to$/, v2: () => '/api/v2/tickets/send' },
{ method: 'POST', v1: /^\/api\/tickets\/generate$/, v2: () => '/api/v2/tickets/generate' },
{ method: 'PUT', v1: /^\/api\/tickets\/email-sent$/, v2: () => '/api/v2/tickets/email-sent' },
{ method: 'GET', v1: /^\/api\/tickets\/qr\/([^/]+)$/, v2: m => `/api/v2/tickets/qr/${m[1]}` },
{ method: 'GET', v1: /^\/api\/tickets\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/tickets` },
{ method: 'GET', v1: /^\/api\/tickets\/transfer-offer\/([^/]+)$/, v2: m => `/api/v2/ticket-transfers/${m[1]}` },
{ method: 'POST', v1: /^\/api\/tickets\/transfer-offer\/([^/]+)\/(accept|decline)$/, v2: m => `/api/v2/ticket-transfers/${m[1]}/${m[2]}` },
{ method: 'GET', v1: /^\/api\/tickets\/?$/, v2: () => '/api/v2/tickets' },
{ method: 'GET', v1: /^\/api\/tickets\/([^/]+)\/transfers$/, v2: m => `/api/v2/tickets/${m[1]}/transfers` },
{ method: 'POST', v1: /^\/api\/tickets\/([^/]+)\/transfer$/, v2: m => `/api/v2/tickets/${m[1]}/transfer` },
{ method: 'DELETE', v1: /^\/api\/tickets\/([^/]+)\/transfer$/, v2: m => `/api/v2/tickets/${m[1]}/transfer` },
{ method: 'GET', v1: /^\/api\/tickets\/([^/]+)$/, v2: m => `/api/v2/tickets/${m[1]}` },
{ method: 'GET', v1: /^\/api\/scan-locks\/?$/, v2: () => '/api/v2/scan-locks' },
{ method: 'POST', v1: /^\/api\/scan-locks\/?$/, v2: () => '/api/v2/scan-locks' },
{ method: 'GET', v1: /^\/api\/scan-locks\/reports$/, v2: () => '/api/v2/scan-locks/reports' },
{ method: 'POST', v1: /^\/api\/scan-locks\/reports\/([^/]+)\/review$/, v2: m => `/api/v2/scan-locks/reports/${m[1]}/review` },
{ method: 'GET', v1: /^\/api\/scan-locks\/([^/]+)\/snapshot$/, v2: m => `/api/v2/scan-locks/${m[1]}/snapshot` },
{ method: 'POST', v1: /^\/api\/scan-locks\/([^/]+)\/(sync|force-release)$/, v2: m => `/api/v2/scan-locks/${m[1]}/${m[2]}` },
{ method: 'DELETE', v1: /^\/api\/scan-locks\/([^/]+)$/, v2: m => `/api/v2/scan-locks/${m[1]}` },
{ method: 'GET', v1: /^\/api\/attendance\/certificates\/verify\/([^/]+)$/, v2: m => `/api/v2/certificates/verify/${m[1]}` },
{ method: 'GET', v1: /^\/api\/attendance\/certificates\/mine$/, v2: () => '/api/v2/me/certificates' },
{ method: 'POST', v1: /^\/api\/attendance\/certificates\/preview$/, v2: () => '/api/v2/certificates/preview' },
{ method: 'GET', v1: /^\/api\/attendance\/certificates\/([^/]+)\/pdf$/, v2: m => `/api/v2/certificates/${m[1]}/pdf` },
{ method: 'POST', v1: /^\/api\/attendance\/certificates\/([^/]+)\/revoke$/, v2: m => `/api/v2/certificates/${m[1]}/revoke` },
{ method: 'GET', v1: /^\/api\/attendance\/([^/]+)\/export$/, v2: m => `/api/v2/events/${m[1]}/attendance/export` },
{ method: 'POST', v1: /^\/api\/attendance\/([^/]+)\/mark$/, v2: m => `/api/v2/events/${m[1]}/attendance` },
{ method: 'POST', v1: /^\/api\/attendance\/([^/]+)\/certificates$/, v2: m => `/api/v2/events/${m[1]}/certificates` },
{ method: 'POST', v1: /^\/api\/attendance\/([^/]+)\/certificates\/send$/, v2: m => `/api/v2/events/${m[1]}/certificates/send` },
{ method: 'GET', v1: /^\/api\/attendance\/([^/]+)\/certificates\.pdf$/, v2: m => `/api/v2/events/${m[1]}/certificates.pdf` },
{ method: 'GET', v1: /^\/api\/attendance\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/attendance` },
{ method: 'GET', v1: /^\/api\/allocations\/my-groups$/, v2: () => '/api/v2/me/allocation-groups' },
{ method: 'GET', v1: /^\/api\/allocations\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/allocations` },
{ method: 'POST', v1: /^\/api\/allocations\/sets$/, v2: () => '/api/v2/allocation-sets' },
{ method: 'PUT', v1: /^\/api\/allocations\/sets\/([^/]+)$/, v2: m => `/api/v2/allocation-sets/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/allocations\/sets\/([^/]+)$/, v2: m => `/api/v2/allocation-sets/${m[1]}` },
{ method: 'GET', v1: /^\/api\/allocations\/sets\/([^/]+)\/lists$/, v2: m => `/api/v2/allocation-sets/${m[1]}/lists` },
{ method: 'POST', v1: /^\/api\/allocations\/sets\/([^/]+)\/(groups|auto)$/, v2: m => `/api/v2/allocation-sets/${m[1]}/${m[2]}` },
{ method: 'PUT', v1: /^\/api\/allocations\/sets\/([^/]+)\/assignments$/, v2: m => `/api/v2/allocation-sets/${m[1]}/assignments` },
{ method: 'DELETE', v1: /^\/api\/allocations\/sets\/([^/]+)\/assignments$/, v2: m => `/api/v2/allocation-sets/${m[1]}/assignments` },
{ method: 'PUT', v1: /^\/api\/allocations\/groups\/([^/]+)$/, v2: m => `/api/v2/allocation-groups/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/allocations\/groups\/([^/]+)$/, v2: m => `/api/v2/allocation-groups/${m[1]}` },
// 1.51.11: money (payments, cash-ups, provider transactions, waitlist, sponsorship, consent, forms, feedback)
{ method: 'GET', v1: /^\/api\/payments\/providers$/, v2: () => '/api/v2/payments/providers' },
{ method: 'GET', v1: /^\/api\/payments\/providers\/settings$/, v2: () => '/api/v2/payments/providers/settings' },
{ method: 'PUT', v1: /^\/api\/payments\/providers\/settings$/, v2: () => '/api/v2/payments/providers/settings' },
{ method: 'GET', v1: /^\/api\/payments\/pay\/([^/]+)$/, v2: m => `/api/v2/payment-links/${m[1]}` },
{ method: 'POST', v1: /^\/api\/payments\/pay\/([^/]+)$/, v2: m => `/api/v2/payment-links/${m[1]}` },
{ method: 'GET', v1: /^\/api\/payments\/checkouts\/open$/, v2: () => '/api/v2/payments/open-checkouts' },
{ method: 'POST', v1: /^\/api\/payments\/checkouts\/check$/, v2: () => '/api/v2/payments/open-checkouts/check' },
{ method: 'POST', v1: /^\/api\/payments\/(yoco-)?checkout\/send$/, v2: () => '/api/v2/registrations/{id}/payment-link' },
{ method: 'GET', v1: /^\/api\/payments\/mypayments$/, v2: () => '/api/v2/me/payments' },
{ method: 'GET', v1: /^\/api\/payments\/admin\/stats$/, v2: () => '/api/v2/payments/stats' },
{ method: 'GET', v1: /^\/api\/payments\/registration\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/payments` },
{ method: 'GET', v1: /^\/api\/payments\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/payments` },
{ method: 'PUT', v1: /^\/api\/payments\/assign-donation$/, v2: () => '/api/v2/payments/donations/assign' },
{ method: 'POST', v1: /^\/api\/payments\/unassign-donation$/, v2: () => '/api/v2/payments/donations/unassign' },
{ method: 'POST', v1: /^\/api\/payments\/refund$/, v2: () => '/api/v2/payments/refunds' },
{ method: 'POST', v1: /^\/api\/payments\/([^/]+)\/send-receipt$/, v2: m => `/api/v2/payments/${m[1]}/receipt` },
{ method: 'GET', v1: /^\/api\/payments\/?$/, v2: () => '/api/v2/payments' },
{ method: 'POST', v1: /^\/api\/payments\/?$/, v2: () => '/api/v2/payments' },
{ method: 'GET', v1: /^\/api\/payments\/([^/]+)$/, v2: m => `/api/v2/payments/${m[1]}` },
{ method: 'GET', v1: /^\/api\/cashups\/audit$/, v2: () => '/api/v2/cashups/audit' },
{ method: 'GET', v1: /^\/api\/cashups\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/cashup` },
{ method: 'GET', v1: /^\/api\/cashups\/event\/([^/]+)\/cash-by-user$/, v2: m => `/api/v2/events/${m[1]}/cashup/cash-by-user` },
{ method: 'PUT', v1: /^\/api\/cashups\/event\/([^/]+)\/person-cash\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/cashup/person-cash/${m[2]}` },
{ method: 'PUT', v1: /^\/api\/cashups\/event\/([^/]+)\/draft$/, v2: m => `/api/v2/events/${m[1]}/cashup/draft` },
{ method: 'POST', v1: /^\/api\/cashups\/event\/([^/]+)\/close$/, v2: m => `/api/v2/events/${m[1]}/cashup/close` },
{ method: 'POST', v1: /^\/api\/cashups\/event\/([^/]+)\/reopen$/, v2: m => `/api/v2/events/${m[1]}/cashup/reopen` },
{ method: 'POST', v1: /^\/api\/cashups\/lines\/([^/]+)\/pending$/, v2: m => `/api/v2/cashup-lines/${m[1]}/pending` },
{ method: 'GET', v1: /^\/api\/(provider|yoco)-transactions\/?$/, v2: () => '/api/v2/provider-transactions' },
{ method: 'GET', v1: /^\/api\/(provider|yoco)-transactions\/unreconciled$/, v2: () => '/api/v2/provider-transactions/unreconciled' },
{ method: 'POST', v1: /^\/api\/(?:provider|yoco)-transactions\/([^/]+)\/reconcile$/, v2: m => `/api/v2/provider-transactions/${m[1]}/reconcile` },
{ method: 'POST', v1: /^\/api\/(?:provider|yoco)-transactions\/([^/]+)\/ignore$/, v2: m => `/api/v2/provider-transactions/${m[1]}/ignore` },
{ method: 'POST', v1: /^\/api\/waitlist\/?$/, v2: () => '/api/v2/waitlist' },
{ method: 'GET', v1: /^\/api\/waitlist\/mine$/, v2: () => '/api/v2/me/waitlist' },
{ method: 'GET', v1: /^\/api\/waitlist\/claim\/([^/]+)$/, v2: m => `/api/v2/waitlist/claims/${m[1]}` },
{ method: 'GET', v1: /^\/api\/waitlist\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/waitlist` },
{ method: 'POST', v1: /^\/api\/waitlist\/([^/]+)\/(offer|move|remove)$/, v2: m => `/api/v2/waitlist/${m[1]}/${m[2]}` },
{ method: 'DELETE', v1: /^\/api\/waitlist\/([^/]+)$/, v2: m => `/api/v2/waitlist/${m[1]}` },
{ method: 'GET', v1: /^\/api\/sponsorships\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/sponsorship` },
{ method: 'GET', v1: /^\/api\/sponsorships\/event\/([^/]+)\/pool$/, v2: m => `/api/v2/events/${m[1]}/sponsorship/pool` },
{ method: 'POST', v1: /^\/api\/sponsorships\/checkout$/, v2: () => '/api/v2/sponsorships/checkout' },
{ method: 'GET', v1: /^\/api\/consent\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/consent` },
{ method: 'GET', v1: /^\/api\/consent\/events\/([^/]+)\/medical$/, v2: m => `/api/v2/events/${m[1]}/consent/medical` },
{ method: 'POST', v1: /^\/api\/consent\/events\/([^/]+)\/(resend-pending|rerequest)$/, v2: m => `/api/v2/events/${m[1]}/consent/${m[2]}` },
{ method: 'POST', v1: /^\/api\/consent\/requests\/([^/]+)\/resend$/, v2: m => `/api/v2/consent-requests/${m[1]}/resend` },
{ method: 'GET', v1: /^\/api\/consent\/([^/]+)$/, v2: m => `/api/v2/consent/${m[1]}` },
{ method: 'POST', v1: /^\/api\/consent\/([^/]+)$/, v2: m => `/api/v2/consent/${m[1]}` },
{ method: 'GET', v1: /^\/api\/forms\/responses$/, v2: () => '/api/v2/form-responses' },
{ method: 'GET', v1: /^\/api\/feedback\/summary$/, v2: () => '/api/v2/feedback/summary' },
{ method: 'GET', v1: /^\/api\/feedback\/public\/([^/]+)$/, v2: m => `/api/v2/feedback-forms/${m[1]}` },
{ method: 'POST', v1: /^\/api\/feedback\/public\/([^/]+)$/, v2: m => `/api/v2/feedback-forms/${m[1]}` },
{ method: 'GET', v1: /^\/api\/feedback\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/feedback` },
{ method: 'PUT', v1: /^\/api\/feedback\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/feedback` },
{ method: 'DELETE', v1: /^\/api\/feedback\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/feedback` },
{ method: 'POST', v1: /^\/api\/feedback\/event\/([^/]+)\/send-now$/, v2: m => `/api/v2/events/${m[1]}/feedback/send-now` },
// 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();
};
+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 };
+2
View File
@@ -22,6 +22,8 @@ import './resources/peopleMessaging';
import './resources/adminPrivacy';
import './resources/checkin';
import './resources/auth';
import './resources/ai';
import './resources/feedback';
const pkg = require('../../package.json') as { version: string };
+16
View File
@@ -42,6 +42,22 @@ const REDIRECTS: { method: string; v1: RegExp; v2: (m: RegExpMatchArray) => stri
{ 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) {
+1 -1
View File
@@ -107,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' }],
+1 -1
View File
@@ -88,7 +88,7 @@ v1Route({ method: 'post', path: '/privacy/operators', tag: 'Privacy', summary: '
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.array(D.str), actors: z.array(D.obj({ id: D.str, name: D.str })) }) }, v1: privacy.accessLog });
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 });
+194
View File
@@ -0,0 +1,194 @@
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, REPORTS_WITH_PEOPLE, REPORT_KEYS, SENSITIVE_REPORTS, type FeedbackQuestion } from '../../ai/helpers';
const ACCESS_AI_REPORT = 'Report summarised by the AI helper (anonymised totals)';
/* 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; what?: string; eventId?: string; records?: number; sensitive?: boolean }): 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. Reports without people in them (${REPORTS_WITHOUT_PEOPLE.join(', ')}) are sent as they are on screen; reports that list people (${REPORTS_WITH_PEOPLE.join(', ')}) only as anonymised totals: a column that would carry a person (name, email, notes, …) is refused with \`AI_REPORT_HAS_PEOPLE\`, and each one is recorded in the access log. ${AI_DESCRIPTION}`,
...helperAccess,
body: z.object({
reportKey: z.enum(REPORT_KEYS),
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, req }) {
const { summary, status: s } = await summariseReport(user!.id, body);
if ((REPORTS_WITH_PEOPLE as readonly string[]).includes(body.reportKey)) {
await logDataAccess(req, { action: 'ai_report', what: `${ACCESS_AI_REPORT}: ${body.title}`, records: body.rows.length, sensitive: SENSITIVE_REPORTS.includes(body.reportKey) });
}
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 } };
},
});
+85
View File
@@ -0,0 +1,85 @@
/**
* The Feedback page (2.1.0): see services/feedback.ts. Supervisors and admins; campus-scoped
* supervisors see their campuses' events. Setting a form up, "Send now" and the summary report
* stay in money.ts.
*/
import { z } from 'zod';
import { defineRoute } from '../route';
import { single } from '../paging';
import { id, idParams } from '../schemas';
import * as feedback from '../../services/feedback';
/* eslint-disable @typescript-eslint/no-explicit-any -- campus scope and the access log are JavaScript */
const cs = require('../../utils/campusScope') as {
scopeEvent(opts?: { need?: 'view' | 'manage' }): any;
scopeList: any;
listWhere(req: unknown, path?: string): Promise<object>;
};
const { logDataAccess } = require('../../utils/accessLog') as { logDataAccess(req: unknown, entry: { action: string; what?: string; eventId?: string; records?: number }): Promise<void> };
const event = z.object({ id, title: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date() });
const status = z.enum(['sent', 'scheduled', 'not_scheduled']).describe('sent, scheduled (a send time is set) or not_scheduled');
const when = z.coerce.date().nullable();
const count = z.object({ label: z.string(), value: z.number().int() });
export const overview = defineRoute({
method: 'get', path: '/feedback', tag: 'Feedback', summary: 'Every event with a feedback form',
description: 'Newest event first, with how many were asked and answered, and the average star rating.',
access: 'supervisor', campusScope: 'scopeList (the handler filters by listWhere)', middleware: [cs.scopeList],
response: single(z.array(z.object({
event, status, sendAt: when, sentAt: when, anonymous: z.boolean(),
invited: z.number().int(), answered: z.number().int(), responseRate: z.number().nullable().describe('%'),
averageRating: z.number().nullable().describe('Every star rating on the form, 1–5'), lastReminderAt: when,
}))),
async handler({ req }) { return { data: await feedback.feedbackOverview(await cs.listWhere(req, 'event')) }; },
});
export const results = defineRoute({
method: 'get', path: '/events/:id/feedback/results', tag: 'Feedback', summary: 'An event\'s feedback: results, responses and who was asked',
description: 'On an anonymous form, responses have no name and `people` is null. Opening a named form\'s answers is recorded in the access log.',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams,
response: single(z.object({
event,
form: z.object({
status, sendAt: when, sentAt: when, audience: z.enum(['checked_in', 'all']), anonymous: z.boolean(), intro: z.string().nullable(),
autoReminderAt: when.describe('When the automatic reminder goes (3 days after sending)'), autoReminderSent: z.boolean(),
}),
counts: z.object({
invited: z.number().int(), answered: z.number().int(), notAnswered: z.number().int(), responseRate: z.number().nullable(),
canRemind: z.number().int().describe('Not answered, and not sent the form or a reminder in the last 24 hours'),
remindedRecently: z.number().int(),
waiting: z.number().int().describe('Would get the form but haven\'t been sent it yet (e.g. checked in after it went out)'),
}),
averageRating: z.number().nullable(),
questions: z.array(z.object({
fieldId: id, type: z.string(), label: z.string(), answered: z.number().int(),
average: z.number().nullable().optional(), distribution: z.array(count).optional(), counts: z.array(count).optional(),
texts: z.array(z.object({ value: z.string(), name: z.string().optional() })).optional(),
})),
fields: z.array(z.object({ id, type: z.string(), label: z.string() })),
responses: z.array(z.object({ id, createdAt: z.coerce.date(), name: z.string().nullable(), answers: z.record(z.string(), z.string()).describe('Question id → answer') })),
people: z.array(z.object({
userId: id, name: z.string(), email: z.string().nullable(), phoneNumber: z.string().nullable(),
sentAt: z.coerce.date(), remindedAt: when, reminderCount: z.number().int(), respondedAt: when, canRemind: z.boolean(),
})).nullable(),
})),
async handler({ params, req }) {
const data = await feedback.eventFeedback(params.id);
if (!data.form.anonymous && data.responses.length) {
await logDataAccess(req, { action: 'feedback_responses', what: `Feedback answers: ${data.event.title}`, eventId: data.event.id, records: data.responses.length });
}
return { data };
},
});
export const remind = defineRoute({
method: 'post', path: '/events/:id/feedback/remind', tag: 'Feedback', summary: 'Remind people who haven\'t answered',
description: 'A fresh link to everyone who hasn\'t answered, or to `userIds` (named forms only). Nobody is sent the form or a reminder twice within 24 hours. Without `userIds` it also sends the form to anyone who has become eligible since it went out. Answers straight away; the emails go out in the background.',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams,
body: z.object({ userIds: z.array(id).max(1000).nullish().describe('Only these people (named forms)') }),
response: single(z.object({ reminded: z.number().int().describe('Being reminded'), newlyInvited: z.number().int().describe('Being sent the form for the first time'), skipped: z.number().int(), queued: z.boolean().describe('Emails are going out in the background'), message: z.string() })),
async handler({ params, body }) {
const { title: _title, ...data } = await feedback.remindFeedback(params.id, { userIds: body.userIds });
return { data };
},
});
+2 -2
View File
@@ -204,7 +204,7 @@ v1Route({
});
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: D.anyObj.nullish() }) }, v1: cashup.getCashByRecordedUser,
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',
@@ -361,6 +361,6 @@ v1Route({
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',
method: 'post', path: '/events/:id/feedback/send-now', tag: 'Feedback', summary: 'Send the feedback form now', description: 'Answers straight away: `invited` is how many it is going to; the emails go out in the background.',
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,
});
+26 -1
View File
@@ -9,7 +9,8 @@ const mockDb = {
};
jest.mock('../src/config/db', () => mockDb);
const mockCompare = jest.fn(async () => false);
jest.mock('../src/config/auth', () => ({ comparePassword: (...a: unknown[]) => (mockCompare as jest.Mock)(...a), hashPassword: async () => 'stand-in-hash', generateToken: () => 't' }));
const mockNeedsRehash = jest.fn(() => false);
jest.mock('../src/config/auth', () => ({ comparePassword: (...a: unknown[]) => (mockCompare as jest.Mock)(...a), hashPassword: async () => 'stand-in-hash', generateToken: () => 't', needsRehash: (...a: unknown[]) => (mockNeedsRehash as jest.Mock)(...a) }));
const mockSendMail = jest.fn(async () => undefined);
jest.mock('../src/utils/email', () => ({ sendMail: (...a: unknown[]) => (mockSendMail as jest.Mock)(...a), buildPasswordResetEmail: () => ({ html: '', text: 'reset' }) }));
jest.mock('../src/utils/notify', () => ({ waText: async () => undefined }));
@@ -81,3 +82,27 @@ describe('sign-in', () => {
expect(mockCompare).toHaveBeenCalledWith('guess', 'stand-in-hash');
});
});
describe('password re-hashing (2.0)', () => {
// Stop logIn at its first save, to see what it saves.
const firstSave = () => new Promise<Record<string, unknown>>(resolve => {
mockDb.user.update.mockImplementationOnce(async (args: { data: Record<string, unknown> }) => { resolve(args.data); throw new Error('stop'); });
});
test('a right password on an old hash saves a new hash', async () => {
mockDb.user.findFirst.mockResolvedValueOnce({ ...realUser, password: '$2a$10$old' });
mockCompare.mockResolvedValueOnce(true as never);
mockNeedsRehash.mockReturnValueOnce(true);
const saved = firstSave();
await logIn({ identifier: realUser.email, password: 'right' }, { ip: '127.0.0.1', userAgent: 'test' } as never).catch(() => undefined);
expect(await saved).toMatchObject({ failedAttempts: 0, password: 'stand-in-hash' });
});
test('a current hash is left as it is', async () => {
mockDb.user.findFirst.mockResolvedValueOnce(realUser);
mockCompare.mockResolvedValueOnce(true as never);
const saved = firstSave();
await logIn({ identifier: realUser.email, password: 'right' }, { ip: '127.0.0.1', userAgent: 'test' } as never).catch(() => undefined);
expect(await saved).not.toHaveProperty('password');
});
});
+134
View File
@@ -0,0 +1,134 @@
// AI helpers (2.0): what the buttons do, and how a helper call goes through the Hub (§3b).
const mockCalls: Array<Record<string, unknown>> = [];
const mockSettings = new Map<string, string>();
const mockDb = {
appSetting: {
findUnique: jest.fn(async ({ where }: { where: { key: string } }) => (mockSettings.has(where.key) ? { key: where.key, value: mockSettings.get(where.key) } : null)),
upsert: jest.fn(async ({ where, update }: { where: { key: string }; update: { value: string } }) => { mockSettings.set(where.key, update.value); }),
},
aiCall: {
count: jest.fn(async () => mockCalls.filter(c => c.outcome === 'ok').length),
create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => { mockCalls.push(data); return data; }),
deleteMany: jest.fn(async () => ({ count: 0 })),
},
user: { findMany: jest.fn(async () => [{ email: 'admin@test.local' }]) },
};
jest.mock('../src/config/db', () => mockDb);
const mockLicence = { connected: true, readOnly: false, state: 'active', claims: { limits: { aiMonthlyCapCents: 5000 } } as { limits: { aiMonthlyCapCents: number } } | null };
jest.mock('../src/hub/licence', () => ({ licenceStatus: jest.fn(async () => mockLicence) }));
class MockHubError extends Error {
constructor(message: string, public code: string, public statusCode: number, public data: unknown = null) { super(message); }
}
const mockPost = jest.fn();
jest.mock('../src/hub/client', () => ({ PROTOCOL_VERSION: 1, HubError: MockHubError, signedPost: (...a: unknown[]) => mockPost(...a) }));
const mockMail = jest.fn(async (_m: unknown) => undefined);
jest.mock('../src/utils/email', () => ({ sendMail: (m: unknown) => mockMail(m), emailWrapper: (s: string) => s, isSmtpConfigured: () => true }));
jest.mock('../src/utils/adminAudit', () => ({ logAdminAction: jest.fn(async () => undefined) }));
import { aiStatus, runHelper, saveAiSettings, clearAiStatusCache, _internal } from '../src/ai';
const hubStatus = (over = {}) => ({ enabled: true, capReached: false, capCents: 5000, usedCents: 100, usedPercent: 2, renewsOn: '2026-12-01', ...over });
const req = { system: 'Write a friendly message.', input: 'Bring a sleeping bag.', maxTokens: 500 };
beforeEach(() => {
mockCalls.length = 0;
mockSettings.clear();
mockPost.mockReset();
mockMail.mockClear();
mockLicence.connected = true;
mockLicence.readOnly = false;
mockLicence.claims = { limits: { aiMonthlyCapCents: 5000 } };
clearAiStatusCache();
});
describe('status', () => {
test('a licence without AI never asks the Hub', async () => {
mockLicence.claims = { limits: { aiMonthlyCapCents: 0 } };
expect(await aiStatus()).toMatchObject({ enabled: false, capReached: false });
mockLicence.connected = false;
expect((await aiStatus()).enabled).toBe(false);
expect(mockPost).not.toHaveBeenCalled();
});
test('asks the Hub and caches its answer for a minute', async () => {
mockPost.mockResolvedValue(hubStatus());
expect(await aiStatus()).toMatchObject({ enabled: true, available: true, usedCents: 100, helpers: { draft_event: true } });
await aiStatus();
expect(mockPost).toHaveBeenCalledTimes(1);
expect(mockPost).toHaveBeenCalledWith('/api/installs/ai/status', { protocolVersion: 1 });
});
test('a Hub without AI (404) means off; an unreachable Hub keeps the buttons, marked unavailable', async () => {
mockPost.mockRejectedValueOnce(new MockHubError('Not found', 'NOT_FOUND', 404));
expect((await aiStatus()).enabled).toBe(false);
clearAiStatusCache();
mockPost.mockRejectedValueOnce(new MockHubError('down', 'HUB_UNREACHABLE', 502));
expect(await aiStatus()).toMatchObject({ enabled: true, available: false });
});
});
describe('running a helper', () => {
test('sends the request to the Hub, logs the call, returns the answer', async () => {
mockPost.mockResolvedValueOnce(hubStatus()).mockResolvedValueOnce({ output: 'Hi all!', chargedCents: 0.31, status: hubStatus({ usedCents: 100.31 }) });
const r = await runHelper('draft_message', 'u1', req);
expect(r.output).toBe('Hi all!');
expect(r.status.usedCents).toBe(100.31);
expect(mockPost).toHaveBeenLastCalledWith('/api/installs/ai/complete', { protocolVersion: 1, helper: 'draft_message', ...req }, { timeoutMs: 90000 });
expect(mockCalls).toEqual([{ userId: 'u1', helper: 'draft_message', outcome: 'ok', chargedCents: 0.31 }]);
});
test('a helper switched off, or the cap reached, never reaches the Hub', async () => {
mockPost.mockResolvedValue(hubStatus({ capReached: true }));
await expect(runHelper('draft_message', 'u1', req)).rejects.toMatchObject({ code: 'FORBIDDEN', reason: 'AI_CAP_REACHED' });
clearAiStatusCache();
mockPost.mockResolvedValue(hubStatus());
await saveAiSettings({ helpers: { draft_message: false } }, { id: 'a1', role: 'admin' });
await expect(runHelper('draft_message', 'u1', req)).rejects.toMatchObject({ reason: 'AI_HELPER_OFF' });
expect(mockPost).not.toHaveBeenCalledWith('/api/installs/ai/complete', expect.anything(), expect.anything());
});
test('the per-person daily limit', async () => {
await saveAiSettings({ dailyLimitPerPerson: 1 }, { id: 'a1', role: 'admin' });
mockPost.mockResolvedValueOnce(hubStatus()).mockResolvedValueOnce({ output: 'x', chargedCents: 0.1, status: hubStatus() });
await runHelper('draft_message', 'u1', req);
await expect(runHelper('draft_message', 'u1', req)).rejects.toMatchObject({ code: 'RATE_LIMITED', reason: 'AI_DAILY_LIMIT' });
});
test('the Hub refusing at the cap updates the status, so the buttons disable at once', async () => {
mockPost.mockResolvedValueOnce(hubStatus())
.mockRejectedValueOnce(new MockHubError('used up', 'AI_CAP_REACHED', 402, { code: 'AI_CAP_REACHED', status: hubStatus({ capReached: true }) }));
await expect(runHelper('draft_message', 'u1', req)).rejects.toMatchObject({ reason: 'AI_CAP_REACHED', message: expect.stringContaining('1 December') });
expect((await aiStatus()).capReached).toBe(true);
expect(mockCalls[0]).toMatchObject({ outcome: 'cap_reached', chargedCents: 0 });
});
test('admins are emailed once a month at 80%', async () => {
const at80 = hubStatus({ usedPercent: 81, usedCents: 4050 });
mockPost.mockResolvedValueOnce(hubStatus()).mockResolvedValue({ output: 'x', chargedCents: 1, status: at80 });
await runHelper('draft_message', 'u1', req);
await runHelper('draft_message', 'u1', req);
expect(mockMail).toHaveBeenCalledTimes(1);
expect(mockMail.mock.calls[0]).toEqual([expect.objectContaining({ to: 'admin@test.local', subject: 'AI allowance 81% used' })]);
});
});
describe('Hub errors', () => {
test.each([
['AI_REFUSED', 'UNAVAILABLE', 'refused'],
['AI_INCOMPLETE', 'UNAVAILABLE', 'incomplete'],
['AI_OFF', 'FORBIDDEN', 'unavailable'],
['HUB_UNREACHABLE', 'UNAVAILABLE', 'unavailable'],
])('%s → %s, logged as %s', (hubCode, code, outcome) => {
const r = _internal.fromHubError({ code: hubCode, message: 'x' });
expect(r.error.code).toBe(code);
expect(r.outcome).toBe(outcome);
});
test('today starts at midnight South African time', () => {
expect(_internal.startOfToday(new Date('2026-11-10T22:30:00Z')).toISOString()).toBe('2026-11-10T22:00:00.000Z');
expect(_internal.startOfToday(new Date('2026-11-10T10:00:00Z')).toISOString()).toBe('2026-11-09T22:00:00.000Z');
});
});
+139
View File
@@ -0,0 +1,139 @@
// The five AI helpers (2.0): what they send, what they keep back, and the checks done in code.
const mockRun = jest.fn();
jest.mock('../src/ai/index', () => ({ runHelper: (...a: unknown[]) => mockRun(...a) }));
jest.mock('../src/config/db', () => ({}));
import {
todayLine, protect, restore, weekdayClashes, eventWarnings, reportText, feedbackText,
draftEvent, summariseReport, draftMessage, improveText, type EventDraftAnswer,
} from '../src/ai/helpers';
const status = { enabled: true, capReached: false, available: true, capCents: 5000, usedCents: 1, usedPercent: 0, renewsOn: '2026-12-01', helpers: {} };
const now = new Date('2026-10-30T08:00:00Z');
beforeEach(() => mockRun.mockReset());
const camp: EventDraftAnswer = {
title: 'Rooted Youth Camp', summary: '', description: '', startDate: '2027-03-14', startTime: '18:00', endDate: '2027-03-16', endTime: '14:00',
location: 'Camp Wartburg', capacity: 60, needsParentalConsent: true, ageGroup: 'Grades 8–12',
ticketOptions: [{ name: 'Camper', priceRand: 450, description: '', earlyBirdPriceRand: 400, earlyBirdUntil: '2027-01-31' }],
sessions: [],
costs: [
{ item: 'Venue', basis: 'R180 × 60 × 2', amountRand: 21600 }, { item: 'Bus', basis: '', amountRand: 6500 },
{ item: 'Speaker gift', basis: '', amountRand: 1000 }, { item: 'T-shirts', basis: 'R95 × 60', amountRand: 5700 },
],
checks: [],
};
describe('today and contact details', () => {
test('today is given in South African time', () => {
expect(todayLine(now)).toBe('Today is Friday 30 October 2026.');
expect(todayLine(new Date('2026-10-30T22:30:00Z'))).toBe('Today is Saturday 31 October 2026.');
});
test('emails, phone numbers and ID numbers never leave the site, and come back in the answer', () => {
const p = protect('Call Sarah on 082 555 1234 or +27 21 555 9876, email youth@church.co.za. ID 9001015009087. Bus R6 500 at 07:30.');
// Emails are found first, then phone numbers, then ID numbers.
expect(p.text).toBe('Call Sarah on [[CONTACT2]] or [[CONTACT3]], email [[CONTACT1]]. ID [[CONTACT4]]. Bus R6 500 at 07:30.');
expect(restore({ body: 'Questions? [[CONTACT1]] or [[CONTACT2]].', list: ['[[CONTACT3]]'] }, p.found))
.toEqual({ body: 'Questions? youth@church.co.za or 082 555 1234.', list: ['+27 21 555 9876'] });
});
test('the same detail twice gets one placeholder', () => {
expect(protect('a@b.co.za and again a@b.co.za').text).toBe('[[CONTACT1]] and again [[CONTACT1]]');
});
});
describe('checks done in code', () => {
test('a weekday that does not match its date', () => {
expect(weekdayClashes('Youth camp Fri 14 March to Sun 16 March', 2027)).toEqual([
'The notes say Friday 14 March, but 14 March 2027 is a Sunday. Check the dates.',
'The notes say Sunday 16 March, but 16 March 2027 is a Tuesday. Check the dates.',
]);
expect(weekdayClashes('Fri 14 March', 2025)).toEqual([]);
});
test('an event that loses money even when full', () => {
expect(eventWarnings('', camp)).toEqual([
'The costs in the notes come to R34 800, which needs 78 places at R450 — more than the 60 available. Even full, the event takes R27 000 (R24 000 if everyone books early), R7 800 short.',
]);
expect(eventWarnings('', { ...camp, capacity: 80 })).toEqual([]);
expect(eventWarnings('', { ...camp, endDate: '2027-03-13' })).toContain('The end is before the start.');
});
});
describe('what the helpers send', () => {
test('draft an event: today\'s date, a schema, contact details kept back; code warnings added', async () => {
mockRun.mockResolvedValue({ output: { ...camp, description: 'Questions? [[CONTACT1]]' }, status });
const r = await draftEvent('u1', 'Youth camp Fri 14 March, R450, 60 places. Questions: youth@church.co.za', now);
const [helper, user, req] = mockRun.mock.calls[0];
expect([helper, user]).toEqual(['draft_event', 'u1']);
expect(req.system).toContain('Today is Friday 30 October 2026.');
expect(req.input).not.toContain('youth@church.co.za');
expect(req.schema.required).toContain('ticketOptions');
expect(r.draft.description).toBe('Questions? youth@church.co.za');
expect(r.totalCostRand).toBe(34800);
expect(r.breakEvenPlaces).toBe(78);
expect(r.warnings.some(w => w.includes('is a Sunday'))).toBe(true);
expect(r.warnings.some(w => w.includes('R7 800 short'))).toBe(true);
});
test('summarise a report: people reports only as anonymised totals', async () => {
for (const columns of [['Event', 'User', 'Amount'], ['Event', 'Email'], ['Event', 'Performed by', 'Notes'], ['Event', 'Attendee', 'Medical'], ['Parent / guardian']]) {
await expect(summariseReport('u1', { reportKey: 'payments', title: 'Payments', columns, rows: [['Jane']] })).rejects.toMatchObject({ reason: 'AI_REPORT_HAS_PEOPLE' });
}
await expect(summariseReport('u1', { reportKey: 'nope', title: 'X', columns: ['A'], rows: [[1]] })).rejects.toMatchObject({ code: 'VALIDATION_FAILED' });
expect(mockRun).not.toHaveBeenCalled();
mockRun.mockResolvedValue({ output: { headline: 'h', insights: [], watchOut: [] }, status });
await summariseReport('u1', { reportKey: 'medicalList', title: 'Medical', columns: ['Event', 'Attendees', 'With allergies', 'With medical aid'], rows: [['Camp', 40, 6, 30]] }, now);
await summariseReport('u1', { reportKey: 'feedbackSummary', title: 'Feedback', columns: ['Event', 'Question', 'Answered', 'Result'], rows: [['Camp', 'Overall', 12, '4.5 / 5']] }, now);
expect(mockRun).toHaveBeenCalledTimes(2);
mockRun.mockReset();
mockRun.mockResolvedValue({ output: { headline: 'h', insights: [], watchOut: [] }, status });
await summariseReport('u1', { reportKey: 'revenue', title: 'Revenue Summary', stats: [{ label: 'Total', value: 'R66 550' }], columns: ['Event', 'Amount'], rows: [['Camp', 'R16 650']] }, now);
expect(mockRun.mock.calls[0][2].input).toBe('Report: Revenue Summary\nTotal: R66 550\nColumns: Event | Amount\nCamp | R16 650');
});
test('report text is capped at 400 rows', () => {
const t = reportText({ reportKey: 'usage', title: 'T', columns: ['A'], rows: Array.from({ length: 450 }, (_, i) => [i]) });
expect(t).toContain('(50 more rows not shown)');
expect(t.split('\n')).toHaveLength(2 + 400 + 1);
});
test('feedback text: averages, counts and numbered comments, no names', () => {
expect(feedbackText({ title: 'Women\'s Conference' }, 112, 186, [
{ label: 'Overall', type: 'rating', answered: 112, average: 4.4 },
{ label: 'Come again?', type: 'yes_no', answered: 100, counts: [{ label: 'Yes', value: 90 }, { label: 'No', value: 10 }] },
{ label: 'Comments', type: 'text', answered: 2, texts: ['Hall B sound please fix it.', 'Loved the worship!'] },
])).toBe([
'Event: Women\'s Conference', 'Responses: 112 of 186 asked', 'Overall — average 4.4 (112 answers)', 'Come again? — Yes: 90, No: 10',
'Comments — comments:', '1. Hall B sound please fix it.', '2. Loved the worship!',
].join('\n'));
});
test('draft a message: WhatsApp has no subject; the event is given as facts', async () => {
mockRun.mockResolvedValue({ output: { subject: 'ignored', body: 'Hi {{name}}!' }, status });
const r = await draftMessage('u1', { channel: 'whatsapp', note: 'bring a sleeping bag', event: { title: 'Youth Camp', start: 'Friday 14 March 2027, 18:00', location: 'Camp Wartburg' } }, now);
expect(r.message).toEqual({ subject: '', body: 'Hi {{name}}!' });
expect(mockRun.mock.calls[0][2].input).toBe('Note: bring a sleeping bag\n\nEvent: Youth Camp, Friday 14 March 2027, 18:00, at Camp Wartburg');
expect(mockRun.mock.calls[0][2].system).toContain('WhatsApp');
expect(mockRun.mock.calls[0][2].system).toContain('{{event.title}}');
});
test('draft a message without an event: no event placeholders offered', async () => {
mockRun.mockResolvedValue({ output: { subject: 'S', body: 'Hi {{name}}' }, status });
await draftMessage('u1', { channel: 'email', note: 'the breakfast moves to the hotel' }, now);
const system = mockRun.mock.calls[0][2].system;
expect(system).toContain('Use no other {{…}} placeholders');
expect(system).not.toContain('{{event.title}}');
});
test('improve text: the mode and where it goes shape the instructions', async () => {
mockRun.mockResolvedValue({ output: { text: 'Fixed.', changes: [] }, status });
await improveText('u1', { text: 'join us for are anual fun day', mode: 'fix', where: 'banner' });
const req = mockRun.mock.calls[0][2];
expect(req.system).toContain('a short banner across the top of the website');
expect(req.system).toContain('Only fix spelling, grammar and punctuation');
});
});
+1 -1
View File
@@ -73,7 +73,7 @@ test('moving keeps the same lines and tickets, repriced; checks places; not othe
test('payments and ticket changes are refused in licence read-only mode; cancelling is not', async () => {
mockLicence.mockResolvedValueOnce({ readOnly: true });
await expect(bulk.runBulk(req, { action: 'markPaid', registrationIds: ['a'], method: 'cash' })).rejects.toMatchObject({ statusCode: 403 });
expect(isBlocked('POST', '/api/registrations/bulk')).toBe(false);
expect(isBlocked('POST', '/api/v2/registrations/bulk')).toBe(false);
});
test('allocating places each named person; a registration without names says so', async () => {
-46
View File
@@ -1,46 +0,0 @@
// Every staff/supervisor route must say how it handles campus-scoped staff (utils/campusScope.js):
// scopeEvent(...), scopeList, unscopedOnly or campusIndependent('reason'). A route that says
// nothing would show a campus-limited supervisor every campus's data, so this fails on it.
const fs = require('fs');
const path = require('path');
const { staff, supervisor } = require('../src/middleware/authMiddleware');
const ROUTES_DIR = path.join(__dirname, '..', 'src', 'routes');
function staffRoutes() {
const out = [];
for (const file of fs.readdirSync(ROUTES_DIR).filter(f => f.endsWith('.js'))) {
const router = require(path.join(ROUTES_DIR, file));
for (const layer of router.stack || []) {
if (!layer.route) continue;
for (const method of Object.keys(layer.route.methods)) {
// router.route('/x').get(...).put(...) puts every method's handlers on one route, so the
// role is checked per method (admin-only methods are never campus-scoped).
const forMethod = layer.route.stack.filter(s => s.method === method || s.method === undefined).map(s => s.handle);
if (!forMethod.includes(staff) && !forMethod.includes(supervisor)) continue;
const marker = forMethod.find(h => h && h.campusScope);
out.push({ file, method: method.toUpperCase(), path: layer.route.path, marker: marker ? marker.campusScope : null });
}
}
}
return out;
}
describe('campus scope on staff and supervisor routes', () => {
const routes = staffRoutes();
test('finds the staff and supervisor routes', () => {
expect(routes.length).toBeGreaterThan(100);
});
test('every one says how it handles campus-scoped staff', () => {
const missing = routes.filter(r => !r.marker).map(r => `${r.file} ${r.method} ${r.path}`);
expect(missing).toEqual([]);
});
test('a campus-independent route gives a reason', () => {
for (const r of routes.filter(x => x.marker && x.marker.startsWith('independent'))) {
expect(r.marker.length).toBeGreaterThan('independent: '.length + 5);
}
});
});
+1 -1
View File
@@ -26,6 +26,6 @@ describe('event cloning', () => {
});
test('copying setup is blocked in licence read-only mode', () => {
expect(isBlocked('POST', '/api/events/abc/copy-from')).toBe(true);
expect(isBlocked('POST', '/api/v2/events/abc/copy-from')).toBe(true);
});
});
+220
View File
@@ -0,0 +1,220 @@
/**
* 2.1.0: the Feedback page (services/feedback.ts) — results per event, anonymous forms that stay
* anonymous, and "Remind" with its 24-hour rule (utils/feedback.js sendFeedbackReminders). 2.1.1:
* "Remind" answers straight away and the emails go out in a background task (runFeedbackTask).
*/
const mockDb: any = {
feedbackForm: { findUnique: jest.fn(), findMany: jest.fn() },
feedbackInvite: { findMany: jest.fn(), updateMany: jest.fn(), count: jest.fn(), create: jest.fn(), groupBy: jest.fn() },
feedbackAnswer: { findMany: jest.fn() },
ticket: { findMany: jest.fn() },
registration: { findMany: jest.fn() },
user: { findMany: jest.fn() },
};
jest.mock('../src/config/db', () => mockDb);
const mockMail = jest.fn(async () => undefined);
jest.mock('../src/utils/email', () => ({ sendMail: (...a: unknown[]) => (mockMail as jest.Mock)(...a), emailWrapper: (h: string) => h, ctaButton: () => '', fallbackLink: () => '' }));
jest.mock('../src/utils/notify', () => ({ waTextAny: jest.fn(async () => undefined) }));
jest.mock('../src/utils/whatsapp', () => ({ isValidZAPhone: () => false }));
const mockEnqueue = jest.fn(async () => ({ queued: true }));
jest.mock('../src/jobs', () => ({ enqueueTask: (...a: unknown[]) => (mockEnqueue as jest.Mock)(...a) }));
import * as feedback from '../src/services/feedback';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { sendFeedbackReminders, runFeedbackTask } = require('../src/utils/feedback');
const NOW = new Date('2026-11-10T12:00:00Z');
const hoursAgo = (h: number) => new Date(NOW.getTime() - h * 3600e3);
const EVENT = { id: 'ev1', title: 'Men\'s Breakfast', startDate: new Date('2026-11-01T07:00:00Z'), endDate: new Date('2026-11-01T10:00:00Z') };
const FIELDS = [
{ id: 'f1', type: 'rating', label: 'Overall', options: null },
{ id: 'f2', type: 'text', label: 'What could be better?', options: null },
{ id: 'f0', type: 'statement', label: 'Thanks for coming', options: null },
];
const person = (id: string, name: string) => ({ id, name, email: `${id}@example.com`, phoneNumber: null });
function form(extra: object = {}) {
return {
id: 'form1', eventId: 'ev1', event: EVENT, anonymous: false, audience: 'checked_in', intro: null,
sendAt: hoursAgo(72), sentAt: hoursAgo(72), reminderSentAt: null, fields: FIELDS,
responses: [
{ id: 'r1', createdAt: hoursAgo(60), user: { name: 'Anna' }, answers: [{ fieldId: 'f1', value: '5' }, { fieldId: 'f2', value: 'Longer coffee break' }] },
{ id: 'r2', createdAt: hoursAgo(50), user: { name: 'Ben' }, answers: [{ fieldId: 'f1', value: '4' }] },
],
invites: [
{ userId: 'u1', user: person('u1', 'Anna'), sentAt: hoursAgo(72), remindedAt: null, reminderCount: 0, respondedAt: hoursAgo(60) },
{ userId: 'u2', user: person('u2', 'Ben'), sentAt: hoursAgo(72), remindedAt: null, reminderCount: 0, respondedAt: hoursAgo(50) },
{ userId: 'u3', user: person('u3', 'Cara'), sentAt: hoursAgo(72), remindedAt: hoursAgo(2), reminderCount: 1, respondedAt: null },
{ userId: 'u4', user: person('u4', 'Dumi'), sentAt: hoursAgo(72), remindedAt: null, reminderCount: 0, respondedAt: null },
{ userId: 'u5', user: person('u5', 'Eve'), sentAt: hoursAgo(1), remindedAt: null, reminderCount: 0, respondedAt: null },
],
...extra,
};
}
beforeEach(() => {
jest.clearAllMocks();
mockDb.ticket.findMany.mockResolvedValue([]);
mockDb.registration.findMany.mockResolvedValue([]);
mockDb.user.findMany.mockResolvedValue([]);
mockDb.feedbackInvite.updateMany.mockResolvedValue({ count: 1 });
});
describe('averageRating', () => {
test('averages every valid star rating, to one decimal', () => {
expect(feedback.averageRating(['5', '4', '4'])).toBe(4.3);
expect(feedback.averageRating(['x', '9', '0'])).toBeNull();
expect(feedback.averageRating([])).toBeNull();
});
});
describe('one event\'s feedback', () => {
test('a named form: names on answers, everyone asked, not answered first', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
const data = await feedback.eventFeedback('ev1', NOW);
expect(data.counts).toMatchObject({ invited: 5, answered: 2, notAnswered: 3, responseRate: 40 });
expect(data.averageRating).toBe(4.5);
expect(data.fields.map((f: any) => f.id)).toEqual(['f1', 'f2']); // statements aren't questions
expect(data.responses[0]).toMatchObject({ name: 'Anna', answers: { f1: '5', f2: 'Longer coffee break' } });
expect(data.people!.map((p: any) => p.name)).toEqual(['Cara', 'Dumi', 'Eve', 'Anna', 'Ben']);
});
test('nobody is remindable within 24 hours of the form or a reminder', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
const data = await feedback.eventFeedback('ev1', NOW);
const can = Object.fromEntries(data.people!.map((p: any) => [p.name, p.canRemind]));
expect(can).toEqual({ Cara: false, Dumi: true, Eve: false, Anna: false, Ben: false });
expect(data.counts).toMatchObject({ canRemind: 1, remindedRecently: 2 });
});
test('an anonymous form: no names anywhere, and no list of who has answered', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form({ anonymous: true }));
const data = await feedback.eventFeedback('ev1', NOW);
expect(data.people).toBeNull();
expect(data.responses.every((r: any) => r.name === null)).toBe(true);
expect(JSON.stringify(data)).not.toMatch(/Anna|Ben|Cara|u1@example/);
expect(data.counts.notAnswered).toBe(3);
});
test('an event without a form is a 404', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(null);
await expect(feedback.eventFeedback('ev1', NOW)).rejects.toMatchObject({ statusCode: 404 });
});
});
describe('Remind', () => {
test('not before the form has gone out', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form({ sentAt: null }));
await expect(feedback.remindFeedback('ev1')).rejects.toMatchObject({ statusCode: 409 });
});
test('not one by one on an anonymous form', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form({ anonymous: true }));
await expect(feedback.remindFeedback('ev1', { userIds: ['u4'] })).rejects.toMatchObject({ statusCode: 400 });
});
// count() is called for: everyone not answered, then those remindable now (the 24-hour rule).
const counts = (notAnswered: number, remindable: number) => mockDb.feedbackInvite.count.mockResolvedValueOnce(notAnswered).mockResolvedValueOnce(remindable);
test('answers straight away with who it will remind; the emails go in the background', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
counts(3, 1);
mockDb.feedbackInvite.findMany.mockResolvedValue([{ userId: 'u3' }, { userId: 'u4' }, { userId: 'u5' }]);
const out = await feedback.remindFeedback('ev1', {}, NOW);
expect(out).toMatchObject({ reminded: 1, newlyInvited: 0, skipped: 2, queued: true });
expect(out.message).toBe('Reminding 1 person. It carries on in the background. 2 people were sent the form or a reminder in the last 24 hours, so not again yet.');
const where = mockDb.feedbackInvite.count.mock.calls[1][0].where;
expect(where).toMatchObject({ formId: 'form1', respondedAt: null });
expect(where.sentAt.lt).toEqual(new Date(NOW.getTime() - 24 * 3600e3));
expect(where.OR).toHaveLength(2);
expect(mockEnqueue).toHaveBeenCalledWith('feedback-send', { formId: 'form1', kind: 'remind', userIds: null });
expect(mockMail).not.toHaveBeenCalled(); // not in the request
});
test('people who can have the form but haven\'t yet are counted too', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
counts(1, 0);
mockDb.feedbackInvite.findMany.mockResolvedValue([{ userId: 'u1' }]);
mockDb.ticket.findMany.mockResolvedValue([{ userId: 'u1' }, { userId: 'u9' }]);
mockDb.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u9' }]);
const out = await feedback.remindFeedback('ev1', {}, NOW);
expect(out).toMatchObject({ reminded: 0, newlyInvited: 1, queued: true });
expect(out.message).toBe('Sending the form to 1 person for the first time. It carries on in the background. 1 person was sent the form or a reminder in the last 24 hours, so not again yet.');
});
test('one person on a named form', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
counts(1, 1);
const out = await feedback.remindFeedback('ev1', { userIds: ['u4'] }, NOW);
expect(out.message).toBe('Reminding 1 person. It carries on in the background.');
expect(mockDb.feedbackInvite.count.mock.calls[0][0].where.userId).toEqual({ in: ['u4'] });
expect(mockEnqueue).toHaveBeenCalledWith('feedback-send', { formId: 'form1', kind: 'remind', userIds: ['u4'] });
});
test('everyone already reminded today: nothing queued', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
counts(2, 0);
mockDb.feedbackInvite.findMany.mockResolvedValue([]);
const out = await feedback.remindFeedback('ev1', {}, NOW);
expect(out.message).toBe('Everyone who hasn\'t answered was sent the form or a reminder in the last 24 hours. Try again tomorrow.');
expect(mockEnqueue).not.toHaveBeenCalled();
});
});
describe('the background task', () => {
test('remind: invites the newly eligible, then reminds with the 24-hour rule', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
mockDb.feedbackInvite.findMany.mockResolvedValue([{ id: 'i4', userId: 'u4', user: person('u4', 'Dumi') }]);
expect(await runFeedbackTask({ formId: 'form1', kind: 'remind' })).toEqual({ invited: 0, reminded: 1 });
const where = mockDb.feedbackInvite.findMany.mock.calls[0][0].where;
expect(where.sentAt.lt).toBeInstanceOf(Date);
expect(mockDb.feedbackInvite.updateMany.mock.calls[0][0].data.reminderCount).toEqual({ increment: 1 });
// Claimed with the same rule, so a second task running at once skips them.
expect(mockDb.feedbackInvite.updateMany.mock.calls[0][0].where).toMatchObject({ id: 'i4', respondedAt: null, sentAt: expect.any(Object), OR: expect.any(Array) });
expect(mockMail).toHaveBeenCalledWith(expect.objectContaining({ to: 'u4@example.com', subject: 'Reminder: How was Men\'s Breakfast?' }));
});
test('remind one person: no new invites', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
mockDb.feedbackInvite.findMany.mockResolvedValue([{ id: 'i4', userId: 'u4', user: person('u4', 'Dumi') }]);
await runFeedbackTask({ formId: 'form1', kind: 'remind', userIds: ['u4'] });
expect(mockDb.feedbackInvite.findMany.mock.calls[0][0].where.userId).toEqual({ in: ['u4'] });
expect(mockDb.feedbackInvite.create).not.toHaveBeenCalled();
});
test('someone who answers while reminders go out isn\'t reminded', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
mockDb.feedbackInvite.findMany.mockResolvedValue([{ id: 'i4', userId: 'u4', user: person('u4', 'Dumi') }]);
mockDb.feedbackInvite.updateMany.mockResolvedValue({ count: 0 });
expect((await runFeedbackTask({ formId: 'form1', kind: 'remind', userIds: ['u4'] })).reminded).toBe(0);
expect(mockMail).not.toHaveBeenCalled();
});
});
describe('the automatic reminder', () => {
test('still goes only to people never reminded, and counts it', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
mockDb.feedbackInvite.findMany.mockResolvedValue([{ id: 'i4', userId: 'u4', user: person('u4', 'Dumi') }]);
expect(await sendFeedbackReminders('form1')).toBe(1);
expect(mockDb.feedbackInvite.findMany.mock.calls[0][0].where).toEqual({ formId: 'form1', respondedAt: null, remindedAt: null });
expect(mockDb.feedbackInvite.updateMany.mock.calls[0][0].data.reminderCount).toEqual({ increment: 1 });
});
});
describe('overview', () => {
test('newest event first, with status, response rate, average rating and last reminder', async () => {
const older = { ...EVENT, id: 'ev0', title: 'Camp', startDate: new Date('2026-06-01T00:00:00Z') };
mockDb.feedbackForm.findMany.mockResolvedValue([
{ id: 'form0', event: older, sendAt: null, sentAt: null, anonymous: true, _count: { invites: 0, responses: 0 } },
{ id: 'form1', event: EVENT, sendAt: hoursAgo(72), sentAt: hoursAgo(72), anonymous: false, _count: { invites: 4, responses: 3 } },
]);
mockDb.feedbackAnswer.findMany.mockResolvedValue([{ value: '5', field: { formId: 'form1' } }, { value: '3', field: { formId: 'form1' } }]);
mockDb.feedbackInvite.groupBy.mockResolvedValue([{ formId: 'form1', _max: { remindedAt: hoursAgo(5) } }]);
const rows = await feedback.feedbackOverview({});
expect(rows.map((r: any) => [r.event.title, r.status, r.responseRate, r.averageRating])).toEqual([
['Men\'s Breakfast', 'sent', 75, 4],
['Camp', 'not_scheduled', null, null],
]);
expect(rows[0].lastReminderAt).toEqual(hoursAgo(5));
});
});
+16 -30
View File
@@ -72,41 +72,27 @@ describe('heartbeat report', () => {
expect(incomeBand(1001)).toBe('R1 001–R5 000');
expect(incomeBand(19999)).toBe('R5 001–R20 000');
expect(incomeBand(100000)).toBe('R50 001–R100 000');
expect(incomeBand(250000)).toBe('over R100 000');
expect(incomeBand(100001)).toBe('R100 001–R150 000');
expect(incomeBand(250000)).toBe('R150 001–R250 000');
expect(incomeBand(400001)).toBe('R400 001–R700 000');
expect(incomeBand(1000000)).toBe('R700 001–R1 000 000');
expect(incomeBand(1000001)).toBe('R1 000 001–R2 500 000');
expect(incomeBand(5000000)).toBe('R2 500 001–R5 000 000');
expect(incomeBand(9999999)).toBe('R5 000 001–R10 000 000');
expect(incomeBand(10000001)).toBe('over R10 000 000');
expect(incomeBand(null)).toBe('R0–R1 000');
});
test('every licence tier edge is a band edge', () => {
// Cross Code bills the tiers on these (crosscode-site src/data/pricing.mjs).
for (const edge of [50000, 150000, 400000, 1000000, 2500000, 5000000, 10000000]) {
expect(incomeBand(edge)).not.toBe(incomeBand(edge + 1));
}
});
});
describe('read-only mode', () => {
test.each([
['POST', '/api/events'],
['PUT', '/api/events/ev_1'],
['PUT', '/api/events/options/opt_1'],
['PUT', '/api/events/variants/var_1'],
['POST', '/api/registrations'],
['POST', '/api/registrations/manual'],
['PUT', '/api/registrations/abc/options'],
['POST', '/api/payments'],
['POST', '/api/payments/checkout'],
['POST', '/api/payments/pay/chk_1'],
['POST', '/api/waitlist'],
])('%s %s is blocked', (m, p) => expect(isBlocked(m, p)).toBe(true));
test.each([
['POST', '/api/tickets/scan/QR123'],
['GET', '/api/events'],
['PUT', '/api/events/ev_1/notify-recipients'],
['PUT', '/api/registrations/abc'],
['POST', '/api/payments/refund'],
['POST', '/api/privacy/me/requests'],
['POST', '/api/hub/connect'],
['POST', '/api/hub/update-request/cancel'],
['POST', '/api/hub/update-snooze'],
['POST', '/api/users/login'],
['POST', '/api/reports/pdf'],
])('%s %s keeps working', (m, p) => expect(isBlocked(m, p)).toBe(false));
// 1.52.3: the screens use /api/v2, so its routes must be blocked too.
// /api/v2 only (2.0 removed v1).
test.each([
['POST', '/api/v2/events'],
['PATCH', '/api/v2/events/ev_1'],
+4 -4
View File
@@ -20,10 +20,10 @@ describe('allowedWhileImpersonating', () => {
});
test('except ending the session and logging page views', () => {
expect(allowedWhileImpersonating('POST', '/api/admin/impersonate/end')).toBe(true);
expect(allowedWhileImpersonating('POST', '/api/admin/impersonate/log?x=1')).toBe(true);
expect(allowedWhileImpersonating('DELETE', '/api/admin/impersonate/end')).toBe(false);
expect(allowedWhileImpersonating('POST', '/api/admin/impersonate/end/../../users')).toBe(false);
expect(allowedWhileImpersonating('POST', '/api/v2/me/impersonation/end')).toBe(true);
expect(allowedWhileImpersonating('POST', '/api/v2/me/impersonation/log?x=1')).toBe(true);
expect(allowedWhileImpersonating('DELETE', '/api/v2/me/impersonation/end')).toBe(false);
expect(allowedWhileImpersonating('POST', '/api/v2/me/impersonation/end/../../users')).toBe(false);
});
});
+23 -3
View File
@@ -10,7 +10,7 @@ jest.mock('../src/controllers/privacyController', () => ({
oneClickUnsubscribe: (req: { params: { token: string }; body: unknown }, res: { json(b: unknown): void }) => { mockCalls.push({ name: 'one-click', body: req.body }); res.json({ ok: true }); },
}));
import { webhooksRouter, linksRouter, legacyLinkRedirects } from '../src/v2/links';
import { webhooksRouter, linksRouter, legacyLinkRedirects, legacyV1Router, v1Removed } from '../src/v2/links';
// The same order and raw-body handling as app.js.
function app() {
@@ -27,7 +27,9 @@ function app() {
a.use(express.json());
a.use('/api/v2', linksRouter);
a.use(legacyLinkRedirects);
a.post('/api/privacy/unsubscribe/:token/one-click', (_req, res) => { mockCalls.push({ name: 'v1 one-click' }); res.json({ ok: true }); });
a.use('/api', legacyV1Router);
a.use('/api', v1Removed);
a.use((_req, res) => { res.status(404).json({ notFound: true }); });
return a;
}
@@ -72,6 +74,24 @@ describe('links in sent messages', () => {
expect(mockCalls[0]).toEqual({ name: 'one-click', body: { 'List-Unsubscribe': 'One-Click' } });
const old = await request(app()).post('/api/privacy/unsubscribe/tok/one-click').type('form').send('List-Unsubscribe=One-Click');
expect(old.status).toBe(200);
expect(mockCalls[1].name).toBe('v1 one-click');
expect(mockCalls[1]).toEqual({ name: 'one-click', body: { 'List-Unsubscribe': 'One-Click' } });
});
});
describe('v1, removed in 2.0', () => {
test("WAWP's oldest webhook address still reaches the handler", async () => {
await request(app()).post('/api/whatsapp/webhook?secret=s3').send({ event: 'message' });
expect(mockCalls[0]).toEqual({ name: 'whatsapp', body: { event: 'message' }, query: { secret: 's3' } });
});
test('any other v1 address answers 410 and points at v2', async () => {
const res = await request(app()).get('/api/events');
expect(res.status).toBe(410);
expect(res.body).toMatchObject({ code: 'API_V1_REMOVED', message: expect.stringContaining('/api/v2') });
expect((await request(app()).post('/api/users/login').send({})).status).toBe(410);
});
test('an unknown v2 address is still a 404', async () => {
expect((await request(app()).get('/api/v2/nothing-here')).status).toBe(404);
});
});
+37
View File
@@ -0,0 +1,37 @@
// 2.0: new passwords are hashed with Argon2id; bcrypt hashes from before still work and are
// re-hashed the next time the person signs in (services/auth.ts logIn).
const bcrypt = require('bcryptjs');
const { hashPassword, comparePassword, needsRehash } = require('../src/config/auth');
describe('password hashing', () => {
test('new hashes are Argon2id and check out', async () => {
const hash = await hashPassword('Correct-horse-1');
expect(hash.startsWith('$argon2id$')).toBe(true);
expect(await comparePassword('Correct-horse-1', hash)).toBe(true);
expect(await comparePassword('wrong', hash)).toBe(false);
expect(needsRehash(hash)).toBe(false);
});
test('two hashes of the same password differ (salted)', async () => {
expect(await hashPassword('same')).not.toBe(await hashPassword('same'));
});
test('old bcrypt hashes still sign in, and are marked for re-hashing', async () => {
const old = await bcrypt.hash('Legacy-pass-1', 10);
expect(await comparePassword('Legacy-pass-1', old)).toBe(true);
expect(await comparePassword('nope', old)).toBe(false);
expect(needsRehash(old)).toBe(true);
});
test('anything else never matches', async () => {
for (const stored of ['', null, undefined, 'plain-text', '$argon2id$broken']) {
expect(await comparePassword('plain-text', stored)).toBe(false);
}
expect(await comparePassword(undefined, await hashPassword('x'))).toBe(false);
});
test('weaker Argon2 settings are re-hashed too', () => {
expect(needsRehash('$argon2id$v=19$m=4096,t=3,p=1$c2FsdHNhbHQ$aGFzaGhhc2g')).toBe(true);
expect(needsRehash('$argon2i$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$aGFzaGhhc2g')).toBe(true);
});
});
+4 -4
View File
@@ -112,9 +112,9 @@ describe('season passes', () => {
});
test('making, changing and buying passes is blocked in licence read-only mode', () => {
expect(isBlocked('POST', '/api/season-passes')).toBe(true);
expect(isBlocked('PUT', '/api/season-passes/p1')).toBe(true);
expect(isBlocked('POST', '/api/season-passes/p1/buy')).toBe(true);
expect(isBlocked('POST', '/api/season-passes/purchases/pp1/checkout')).toBe(true);
expect(isBlocked('POST', '/api/v2/season-passes')).toBe(true);
expect(isBlocked('PATCH', '/api/v2/season-passes/p1')).toBe(true);
expect(isBlocked('POST', '/api/v2/season-passes/p1/buy')).toBe(true);
expect(isBlocked('POST', '/api/v2/season-pass-purchases/pp1/checkout')).toBe(true);
});
});
+3 -3
View File
@@ -41,9 +41,9 @@ describe('event series', () => {
});
test('creating and changing series is blocked in licence read-only mode; following is not', () => {
expect(isBlocked('POST', '/api/series')).toBe(true);
expect(isBlocked('PUT', '/api/series/abc')).toBe(true);
expect(isBlocked('POST', '/api/series/abc/follow')).toBe(false);
expect(isBlocked('POST', '/api/v2/series')).toBe(true);
expect(isBlocked('PATCH', '/api/v2/series/abc')).toBe(true);
expect(isBlocked('PUT', '/api/v2/series/abc/follow')).toBe(false);
});
describe('year-on-year comparison', () => {
+34
View File
@@ -0,0 +1,34 @@
/**
* 2.1.1: one-off background tasks (jobs/index.js enqueueTask). Without the queue (timers or jobs
* off) a task runs in this process right after the caller gets its answer.
*/
let finish;
const mockTask = jest.fn(() => new Promise(resolve => { finish = resolve; }));
jest.mock('../src/utils/feedback', () => ({ runFeedbackTask: (...a) => mockTask(...a) }));
const jobs = require('../src/jobs');
describe('enqueueTask without the queue', () => {
test('answers before the task has run, then runs it', async () => {
const answer = await jobs.enqueueTask('feedback-send', { formId: 'f1', kind: 'send' });
expect(answer).toEqual({ queued: false });
expect(mockTask).not.toHaveBeenCalled(); // not yet: after the response
await new Promise(r => setImmediate(r));
expect(mockTask).toHaveBeenCalledWith({ formId: 'f1', kind: 'send' });
finish({ invited: 3 });
});
test('a failing task is logged, not thrown at the caller', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
mockTask.mockImplementationOnce(() => Promise.reject(new Error('SMTP down')));
await expect(jobs.enqueueTask('feedback-send', { formId: 'f1', kind: 'send' })).resolves.toEqual({ queued: false });
await new Promise(r => setImmediate(r));
await new Promise(r => setImmediate(r));
expect(warn).toHaveBeenCalledWith('[jobs] task feedback-send failed:', 'SMTP down');
warn.mockRestore();
});
test('an unknown task is refused', async () => {
await expect(jobs.enqueueTask('nope', {})).rejects.toThrow('Unknown task nope');
});
});
+99 -19
View File
@@ -5,11 +5,17 @@ import request from 'supertest';
// way everywhere, and produce the OpenAPI document.
const mockDb = {
event: { findMany: jest.fn(), count: jest.fn(), findFirst: jest.fn() },
event: { findMany: jest.fn(), count: jest.fn(), findFirst: jest.fn(), findUnique: jest.fn() },
user: { findUnique: jest.fn() },
registration: { findMany: jest.fn(), count: jest.fn(), findUnique: jest.fn(), findUniqueOrThrow: jest.fn() },
feedbackForm: { findUnique: jest.fn() },
};
jest.mock('../src/config/db', () => mockDb);
// AI helpers: everything real except the call to the Hub.
const mockRunHelper = jest.fn();
jest.mock('../src/ai/index', () => ({ ...jest.requireActual('../src/ai/index'), runHelper: (...a: unknown[]) => mockRunHelper(...a) }));
const mockAccessLog = jest.fn(async () => undefined);
jest.mock('../src/utils/accessLog', () => ({ logDataAccess: (...a: unknown[]) => mockAccessLog(...(a as [])) }));
jest.mock('../src/utils/campuses', () => ({
campusesEnabled: jest.fn().mockResolvedValue(false),
resolveCampusFilter: jest.fn(async (v: string) => v),
@@ -386,24 +392,6 @@ describe('check-in', () => {
expect(res.body.data.ticket).toMatchObject({ attendee: 'Lulu', bookedBy: 'Thandi', ticketOptionId: 'o1', remaining: 2, consentStatus: 'not_required' });
});
});
describe('v1 deprecation headers', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { successorOf } = require('../src/v2/deprecation');
test('v1 endpoints with a v2 replacement point at it; the rest say nothing', () => {
expect(successorOf('GET', '/api/events')).toBe('/api/v2/events');
expect(successorOf('GET', '/api/events/e1')).toBe('/api/v2/events/e1/full');
expect(successorOf('PUT', '/api/events/options/o1')).toBe('/api/v2/ticket-options/o1');
expect(successorOf('DELETE', '/api/events/e1/attachments/a1')).toBe('/api/v2/events/e1/attachments/a1');
expect(successorOf('GET', '/api/events/all')).toBe('/api/v2/events/manage');
expect(successorOf('PUT', '/api/events/e1')).toBe('/api/v2/events/e1');
expect(successorOf('DELETE', '/api/registrations/r1')).toBe('/api/v2/registrations/r1/cancel');
expect(successorOf('POST', '/api/tickets/scan/QR1')).toBe('/api/v2/checkin/QR1');
expect(successorOf('GET', '/api/reports/summary')).toBeNull();
expect(successorOf('GET', '/api/v2/events')).toBeNull();
});
});
describe('shared middleware on v2', () => {
test("v1-style refusals become v2 errors, keeping the specific code as reason", async () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -693,3 +681,95 @@ describe('money (v1 handlers behind v2 routes)', () => {
expect(res.body.data).toEqual({ params: { lineId: 'l9' }, body: { resolution: 'paid' } });
});
});
describe('AI helpers (2.0)', () => {
const st = { enabled: true, capReached: false, available: true, capCents: 5000, usedCents: 1, usedPercent: 0, renewsOn: '2026-12-01', helpers: { draft_event: true, summarise_report: true, draft_message: true, summarise_feedback: true, improve_text: true } };
test('supervisors only', async () => {
signedIn = { id: 's1', role: 'staff' };
expect((await request(app).post('/api/v2/ai/improve-text').send({ text: 'hello there' })).status).toBe(403);
expect(mockRunHelper).not.toHaveBeenCalled();
});
test('improve text: the answer and the new status', async () => {
signedIn = { id: 's1', role: 'supervisor' };
mockRunHelper.mockResolvedValue({ output: { text: 'Hello there.', changes: ['Capital letter'] }, status: st });
const res = await request(app).post('/api/v2/ai/improve-text').send({ text: 'hello there', mode: 'fix', where: 'banner' });
expect(res.status).toBe(200);
expect(res.body.data).toEqual({ output: { text: 'Hello there.', changes: ['Capital letter'] }, status: st });
expect(mockRunHelper.mock.calls[0][0]).toBe('improve_text');
});
test('summarise a report: unknown reports fail validation, people columns are refused', async () => {
signedIn = { id: 's1', role: 'supervisor' };
expect((await request(app).post('/api/v2/ai/summarise-report').send({ reportKey: 'nope', title: 'X', columns: ['A'], rows: [[1]] })).status).toBe(400);
const res = await request(app).post('/api/v2/ai/summarise-report').send({ reportKey: 'payments', title: 'Payments', columns: ['User'], rows: [['Jane']] });
expect(res.status).toBe(403);
expect(res.body.error.reason).toBe('AI_REPORT_HAS_PEOPLE');
expect(mockRunHelper).not.toHaveBeenCalled();
});
test('summarise a report: a people report as totals, with an access-log entry (sensitive for medical)', async () => {
signedIn = { id: 's1', role: 'supervisor' };
mockRunHelper.mockResolvedValue({ output: { headline: 'h', insights: [], watchOut: [] }, status: st });
const res = await request(app).post('/api/v2/ai/summarise-report').send({ reportKey: 'medicalList', title: 'Medical & emergency list', columns: ['Event', 'Attendees', 'With allergies'], rows: [['Camp', 40, 6]] });
expect(res.status).toBe(200);
expect(mockAccessLog).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: 'ai_report', records: 1, sensitive: true }));
mockAccessLog.mockClear();
await request(app).post('/api/v2/ai/summarise-report').send({ reportKey: 'revenue', title: 'Revenue', columns: ['Event', 'Amount'], rows: [['Camp', 100]] });
expect(mockAccessLog).not.toHaveBeenCalled();
});
test('draft an event: the draft with warnings worked out in code', async () => {
signedIn = { id: 's1', role: 'supervisor' };
const draft = {
title: 'Camp', summary: '', description: '', startDate: '2027-03-14', startTime: '18:00', endDate: '2027-03-16', endTime: '14:00', location: '', capacity: 60, needsParentalConsent: true, ageGroup: '',
ticketOptions: [{ name: 'Camper', priceRand: 450, description: '', earlyBirdPriceRand: 0, earlyBirdUntil: '' }], sessions: [],
costs: [{ item: 'Venue', basis: '', amountRand: 34800 }], checks: [],
};
mockRunHelper.mockResolvedValue({ output: draft, status: st });
const res = await request(app).post('/api/v2/ai/draft-event').send({ notes: 'Camp Fri 14 March, R450, 60 places' });
expect(res.status).toBe(200);
expect(res.body.data.output).toMatchObject({ totalCostRand: 34800, breakEvenPlaces: 78 });
expect(res.body.data.output.warnings.join(' ')).toMatch(/is a Sunday.*R7 800 short/);
});
test('draft a message: the event is given as facts', async () => {
signedIn = { id: 's1', role: 'supervisor' };
mockDb.event.findUnique.mockResolvedValue({ title: 'Youth Camp', startDate: new Date('2027-03-12T16:00:00Z'), location: 'Camp Wartburg' });
mockRunHelper.mockResolvedValue({ output: { subject: '', body: 'Hi {{name}}!' }, status: st });
const res = await request(app).post('/api/v2/ai/draft-message').send({ channel: 'whatsapp', note: 'bring a sleeping bag', eventId: '11111111-1111-4111-8111-111111111111' });
expect(res.status).toBe(200);
expect(res.body.data.output).toEqual({ subject: '', body: 'Hi {{name}}!' });
expect(mockRunHelper.mock.calls[0][2].input).toContain('Event: Youth Camp');
});
test('summarise feedback: answers without who gave them, and an access-log entry', async () => {
signedIn = { id: 's1', role: 'supervisor' };
mockDb.feedbackForm.findUnique.mockResolvedValue({
event: { title: 'Conference' }, _count: { invites: 10 },
fields: [{ id: 'f1', type: 'rating', label: 'Overall' }, { id: 'f2', type: 'textarea', label: 'Comments' }],
responses: [
{ userId: 'u1', user: { name: 'Jane Doe' }, answers: [{ fieldId: 'f1', value: '5' }, { fieldId: 'f2', value: 'Loved it, call me on 082 555 1234' }] },
{ userId: 'u2', user: { name: 'John Roe' }, answers: [{ fieldId: 'f1', value: '3' }] },
],
});
mockRunHelper.mockResolvedValue({ output: { overall: 'Good.', liked: [], improve: [], quotes: [], nextTime: [] }, status: st });
const res = await request(app).post('/api/v2/ai/summarise-feedback').send({ eventId: '11111111-1111-4111-8111-111111111111' });
expect(res.status).toBe(200);
expect(res.body.data.output.responses).toBe(2);
const sent = mockRunHelper.mock.calls[0][2].input;
expect(sent).toContain('Overall — average 4');
expect(sent).not.toMatch(/Jane|John|082 555 1234/);
expect(mockAccessLog).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: 'ai_feedback', records: 2 }));
});
test('Hub errors keep their reason', async () => {
signedIn = { id: 's1', role: 'supervisor' };
const { ApiError } = jest.requireActual('../src/v2/errors');
mockRunHelper.mockRejectedValue(Object.assign(new ApiError('FORBIDDEN', 'Used up.'), { reason: 'AI_CAP_REACHED' }));
const res = await request(app).post('/api/v2/ai/improve-text').send({ text: 'hello there' });
expect(res.status).toBe(403);
expect(res.body.error).toMatchObject({ code: 'FORBIDDEN', reason: 'AI_CAP_REACHED' });
});
});
+53
View File
@@ -0,0 +1,53 @@
// Every staff/supervisor route on /api/v2 must really check campus-scoped staff (utils/campusScope.js).
// A route has to declare `campusScope` (start-up fails otherwise), but the declaration is only text:
// the check itself is a marked middleware — scopeEvent(...), scopeBody(...), scopeList,
// unscopedOnly, campusIndependent('reason') or scopeBroadcast(...). A route without one would show
// a campus-limited supervisor every campus's data, so this fails on it.
//
// (Until 2.0 the same test ran over the v1 route files, which held the markers.)
//
// The routes below check campuses inside their TypeScript service or handler instead, as their
// `campusScope` text says. Adding a route here means its handler does the check itself.
const CHECKED_IN_HANDLER = new Set([
'GET /events/manage', 'POST /events', 'GET /sections', 'POST /uploads/event-image',
'GET /series', 'POST /series', 'PATCH /series/:id', 'GET /series/:id/compare',
'GET /season-passes', 'POST /season-passes', 'PATCH /season-passes/:id', 'DELETE /season-passes/:id',
'GET /coupons', 'GET /coupons/redemptions', 'GET /coupons/:id', 'POST /coupons', 'PATCH /coupons/:id',
'GET /campuses/:id/audience', 'GET /registrations',
'GET /saved-filters', 'POST /saved-filters', 'PUT /saved-filters/:id', 'DELETE /saved-filters/:id',
'GET /checkin/:qrCode', 'POST /checkin/:qrCode',
]);
/* eslint-disable @typescript-eslint/no-require-imports */
const { v2Router } = require('../src/v2');
const { routes } = require('../src/v2/route');
type Route = { method: string; path: string; access: string; campusScope?: string; middleware?: { campusScope?: string }[] };
describe('campus scope on v2 staff and supervisor routes', () => {
v2Router();
const staffRoutes = (routes as Route[]).filter(r => r.access === 'staff' || r.access === 'supervisor');
const key = (r: Route) => `${r.method.toUpperCase()} ${r.path}`;
const marker = (r: Route) => (r.middleware || []).find(h => h && h.campusScope)?.campusScope;
test('finds the staff and supervisor routes', () => {
expect(staffRoutes.length).toBeGreaterThan(150);
});
test('each one has a campus-scope middleware, or checks in its handler', () => {
const missing = staffRoutes.filter(r => !marker(r) && !CHECKED_IN_HANDLER.has(key(r))).map(key);
expect(missing).toEqual([]);
});
test('the handler-checked list holds only real staff routes without a middleware', () => {
const real = new Set(staffRoutes.filter(r => !marker(r)).map(key));
expect([...CHECKED_IN_HANDLER].filter(k => !real.has(k))).toEqual([]);
});
test('a campus-independent route gives a reason', () => {
for (const r of staffRoutes) {
const m = marker(r);
if (m && m.startsWith('independent')) expect(m.length).toBeGreaterThan('independent: '.length + 5);
}
});
});
+6 -6
View File
@@ -80,13 +80,13 @@ describe('reserved tickets', () => {
describe('read-only licence', () => {
test.each([
['POST', '/api/volunteers/roles/r1/sign-up'],
['POST', '/api/volunteers/manage/events/e1/roles'],
['POST', '/api/volunteers/manage/roles/r1/assign'],
['POST', '/api/v2/volunteer-roles/r1/sign-up'],
['POST', '/api/v2/events/e1/volunteer-roles'],
['POST', '/api/v2/volunteer-roles/r1/assign'],
])('%s %s is blocked', (m, p) => expect(isBlocked(m, p)).toBe(true));
test.each([
['POST', '/api/volunteers/check-in'],
['POST', '/api/volunteers/swaps/w1/accept'],
['DELETE', '/api/volunteers/slots/s1'],
['POST', '/api/v2/volunteering/check-in'],
['POST', '/api/v2/volunteer-swaps/w1/accept'],
['DELETE', '/api/v2/volunteer-slots/s1'],
])('%s %s keeps working', (m, p) => expect(isBlocked(m, p)).toBe(false));
});

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