Compare commits

...
111 Commits
Author SHA1 Message Date
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
joshuaandClaude Opus 5.5 b28c8a8876 Fix: licence read-only mode blocks the v2 routes too (1.52.3)
The read-only guard only listed v1 paths, and every screen has used
/api/v2 since 1.51.15, so a suspended, revoked or unlicensed site could
still create events, take registrations and start payments. The v2
successors of each blocked v1 route are blocked now; a test checks each
blocked v2 path exists in the OpenAPI document.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 13:47:49 +02:00
joshuaandClaude Opus 5.5 6ee1438e23 WIP 1.51.11: money on v2 via v1 handlers (viaV1 adapter)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 13:43:36 +02:00
joshua a45417948d Merge branch 'feat/api-v2-registrations-staff' 2026-10-29 13:16:59 +02:00
joshuaandClaude Opus 5.5 31ee2a9aee Managing registrations on API v2 (1.51.10)
- v2: staff list (paged), event registrations, registration in full (+ /me), status, manual
  registration, bulk, items, invoice, coupon, attendees and form links, form drafts and responses.
- src/services/registrationAdmin.ts holds the rest of registrationController.js (now v1 wrappers).
- Frontend: fetchAllRegistrations(); every registration screen on v2.
- Roadmap: money gets its own release (1.51.11); webhooks and links move to 1.51.16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 13:16:59 +02:00
joshuaandClaude Opus 5.5 da67f80d6a WIP 1.51.10: registration management on v2 (routes, frontend)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 13:04:31 +02:00
joshuaandClaude Opus 5.5 4b7fb19f37 WIP 1.51.10: registration management moved to src/services/registrationAdmin.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 12:57:02 +02:00
joshua 0fc4f83c34 Merge branch 'feat/api-v2-series-deals' 2026-10-29 12:39:28 +02:00
joshuaandClaude Opus 5.5 59fde801f7 Series, season passes, deals and campuses on API v2 (1.51.9)
- v2: series (+ page, compare, follow), season passes (+ buy, pay, mine), bundle deals
  (+ quote), coupon codes (+ check, redemptions), campuses (+ admin, enabled, order, audience).
- src/services/series.ts, deals.ts, campuses.ts; the five controllers are thin v1 wrappers.
- v2 errors carry a service's own code as `reason`.
- Frontend: apiV2Data() for screens with their own record types; these screens on v2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 12:39:27 +02:00
joshuaandClaude Opus 5.5 733b23955b WIP 1.51.9: frontend on v2 for series, passes, bundles, coupons, campuses
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 12:34:49 +02:00
joshuaandClaude Opus 5.5 d6082693c2 WIP 1.51.9: series, season passes, bundles, coupons and campuses on v2 (backend)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 12:28:00 +02:00
joshua 16357e6006 Merge branch 'feat/api-v2-events-rest' 2026-10-29 12:18:29 +02:00
joshuaandClaude Opus 5.5 9547793f25 The rest of event set-up on API v2 (1.51.8)
- v2: event in full, calendar file, alias, ticket options and variants, attachments
  (multipart), sections, planned costs, image uploads.
- src/services/eventDetails.ts and src/services/setup.ts; v1 uses them.
- v2 routes can send files (fileResponse) and take uploads (documented as multipart).
- Frontend: fetchEvent() everywhere, lib/eventSetup.ts for set-up calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 12:18:29 +02:00
joshuaandClaude Opus 5.5 99efe7a7d0 WIP 1.51.8: frontend on v2 for event detail, options, sections, attachments, costs, uploads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 12:14:01 +02:00
joshuaandClaude Opus 5.5 3c983070a1 WIP 1.51.8: sections, costs and uploads on v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 12:08:03 +02:00
joshuaandClaude Opus 5.5 c63c3a3604 WIP 1.51.8: event detail, options, variants, attachments, calendar and alias on v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 12:04:06 +02:00
joshua 67d0a83ab8 Merge branch 'feat/api-v2-events' 2026-10-29 11:57:08 +02:00
joshuaandClaude Opus 5.5 2bcc9ac651 Setting events up on API v2, pg-boss 11 (1.51.7)
- v2: /events/manage, create, PATCH, deactivate, copy-from, notify-recipients.
- src/services/events.ts (from eventController.js, with the schedule helpers); v1 uses it.
- fetchManagedEvents() for every picker; the event wizard writes through v2.
- pg-boss 11 in its own schema (pgboss_v11); old pgboss schema left in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 11:57:08 +02:00
joshua b2906011fd Merge branch 'feat/palette-excelts' 2026-10-29 11:16:38 +02:00
joshuaandClaude Opus 5.5 70850df4f6 Complete command palette, ExcelTS 10, roadmap: v2 for webhooks and sent links (1.51.6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 11:14:19 +02:00
joshua cbebcdbd48 Merge branch 'chore/next16' 2026-10-29 11:00:31 +02:00
joshuaandClaude Opus 5.5 849998acb3 Next.js 16, React 19.3, ESLint 10 (1.51.5)
- Next 16 (Turbopack dev and build), React 19.3; tsconfig jsx react-jsx; images remotePatterns.
- ESLint 10 with eslint-config-next 16 flat configs; npm run lint = eslint src. React
  Compiler-era react-hooks rules set to warn (181 existing places) until screens move to v2.
- Removed the empty pass-through middleware.ts.
- e2e: wait for the palette to be ready before Ctrl+K on the staff dashboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 11:00:30 +02:00
joshua c5fe17a1ef Merge branch 'chore/dependencies' 2026-10-29 10:19:43 +02:00
joshuaandClaude Opus 5.5 6164fdcb3b Security and dependency updates, Node 24 for new installs (1.51.4)
- npm audit fixes: next 15.5.28 and @sentry/nextjs 10.78 (critical), zod 4.6.7, multer 2.5.0
  (high), nodemailer 10.3.0, fast-uri, ip-address (moderate). Minimum versions raised.
- cron-parser 5 (v4 unmaintained).
- install.sh installs Node 24 LTS; deploy.sh warns on Node < 22; DEPLOYMENT.md "Upgrading Node".
- Roadmap: every endpoint moves to v2 before 2.0 (1.51.5 - 1.51.11).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 10:19:43 +02:00
joshua 1510c2a057 Merge branch 'chore/roadmap-v1-decision' 2026-10-29 09:49:54 +02:00
joshuaandClaude Opus 5.5 fa4191d18c Roadmap: 2.0 retires only the v1 endpoints that have a v2 replacement
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 09:49:54 +02:00
joshua e0dae5647f Merge branch 'feat/api-v2-express5' 2026-10-29 09:46:24 +02:00
joshuaandClaude Opus 5.5 4109cb4258 Express 5, scanner on API v2, v1 deprecation headers (1.51.3)
- Express 5 (qs query parser and req.body = {} kept as in Express 4).
- Scanner and at-the-door check-in use /api/v2/checkin; look-up gives attendee, bookedBy,
  ticketOptionId.
- Shared middleware errors on /api/v2 rewritten to { error: { code, message, reason } };
  apiFetch treats reason like v1's code.
- Deprecation + Link: rel="successor-version" on v1 endpoints that have a v2 replacement.
- Roadmap: open question on how much of v1 to retire in 2.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 09:46:24 +02:00
joshua ce9734090c Merge branch 'feat/api-v2-checkin-accounts' 2026-10-29 09:31:01 +02:00
joshuaandClaude Opus 5.5 9012ec64b6 API v2 accounts and check-in (1.51.2)
- v2: auth/signup, auth/login (+ code step, rate limited), PATCH /me; check-in look-up and
  scan returning an outcome for every normal result. Campus rule as v1.
- Sign up/login/2FA/profile moved to src/services/auth.ts; scan preview and scan to
  src/services/checkin.ts. v1 answers unchanged (ServiceError v1Status).
- Login, 2FA step, sign up and profile/password pages use v2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 09:31:00 +02:00
joshua 7c252e0cb3 Merge branch 'feat/api-v2-writes' 2026-10-29 09:09:09 +02:00
joshuaandClaude Opus 5.5 735a76cc74 API v2 registrations and payments (1.51.1)
- v2: register, get, cancel and checkout a registration (owner/admin only).
- Registration create/cancel moved to src/services/registrations.ts and online checkout to
  src/services/payments.ts; v1 controllers call the same services (ServiceError carries status).
- Typed frontend calls: npm run api:types writes openapi.json and schema.d.ts; apiV2() helper.
  Register page, Pay now and My Events cancel use v2; apiFetch reads v2 errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 09:09:09 +02:00
joshua e2e881be85 Merge branch 'feat/api-v2-foundations' 2026-10-29 08:48:54 +02:00
joshuaandClaude Opus 5.5 4ca896130b TypeScript backend and /api/v2 foundations (1.51.0)
- tsconfig (allowJs, strict .ts), npm run build -> dist/, typecheck, dev via tsx, ts-jest.
- src/index.js is now a launcher (dist build when it matches, else tsx); server moved to app.js.
- /api/v2: defineRoute with zod -> request validation, response stripping, OpenAPI 3.1 at
  /docs/v2; one error shape and one paged-list shape. First endpoints: events, event, me,
  my registrations.
- deploy.sh/install.sh build the backend; rollback to older releases skips the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 08:48:54 +02:00
joshua d2d8cb1934 Merge branch 'chore/roadmap-ai-markup' 2026-10-29 08:29:15 +02:00
joshuaandClaude Opus 5.5 ad40618267 Roadmap: AI buttons follow the cap, markup on AI usage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 08:29:15 +02:00
joshua fc63454439 Merge branch 'feat/event-pickers-webhook-docs' 2026-10-29 08:23:09 +02:00
joshuaandClaude Opus 5.5 8f6f90a151 Searchable event picker and webhook guide (1.50.2)
- EventPicker replaces the plain event dropdowns on staff/supervisor pages and the kiosk:
  upcoming first, past behind "Show past events", search across all, keyboard support.
- Reports events list can be searched ("Select these N").
- Site settings -> Webhooks: "How webhooks work" guide with headers, signature checking
  (Node.js/PHP) and an example of every event type (EVENT_TYPES[].example, shape-tested).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 08:23:09 +02:00
joshua dacd49652b Merge branch 'feat/long-lists' 2026-10-29 06:53:54 +02:00
joshuaandClaude Opus 5.5 4c066e67d9 Pagination and search on long lists (1.50.1)
Audit log: search (person, target, IP), role filter, every action in the
filter, inclusive To date. Data requests: search and paging (was cut at
200). Webhook deliveries: status filter and Load older. Supervisor Payments
loads only what it needs (unpaid registrations, recent/today/donation
payments, a person's own for refunds) instead of everything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 06:53:54 +02:00
joshua 0ffbb13a1f Merge branch 'feat/accessibility' 2026-10-28 15:41:19 +02:00
joshuaandClaude Opus 5.5 e9d1b1fb24 Accessibility pass, WCAG 2.2 AA (1.50.0)
Public pages, auth, registration, payment, My Events and the kiosk: skip
link, focus ring, labels tied to fields, named quantity buttons, announced
errors, ModalOverlay popups (focus trap, Escape, focus return), contrast
and underlined in-text links, reduced motion. Branding warns about a primary
colour too light for white text and offers a darker shade. axe-core and
keyboard checks in e2e/accessibility.spec.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:41:19 +02:00
joshua 98173c1380 Merge branch 'chore/roadmap-no-afrikaans' 2026-10-28 15:02:22 +02:00
joshuaandClaude Opus 5.5 6297613d0d Roadmap: languages (Afrikaans) removed from scope
The accessibility pass becomes 1.50.0 and the TypeScript backend /
OpenAPI / api v2 1.51.0. Section 10 is kept for reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:02:22 +02:00
joshua 5efb4cd6dd Merge branch 'feat/saved-filters' 2026-10-28 14:57:34 +02:00
joshuaandClaude Opus 5.5 e40803cbbc Saved filters on list pages (1.49.2)
Manage registrations, Users, Events and Reports: save the current
filters under a name, apply one with a click, star one as the default
for the page (not applied over a deep link). Per person, stored on the
server, removed when an account is anonymised. /api/saved-filters.
Migration 20261028180000_saved_filters. Completes roadmap section 9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:57:34 +02:00
joshua fc393a90c1 Merge branch 'feat/bulk-actions' 2026-10-28 14:43:01 +02:00
joshuaandClaude Opus 5.5 c1211db8cf Bulk actions on registrations (1.49.1)
Manage registrations: tick registrations (or all shown) and message
them (email/WhatsApp event update), mark them paid (the balance, by
method), move them to another ticket (same tickets and QR codes,
repriced, places checked), put their people in a room or group, export
to Excel, or cancel. Each runs the same handlers as doing it one at a
time, with a result per registration. POST /api/registrations/bulk,
campus-checked per id. Migration 20261028170000_registrations_bulk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:43:00 +02:00
joshua fb42d6ac19 Merge branch 'feat/dashboard-palette' 2026-10-28 14:17:23 +02:00
joshuaandClaude Opus 5.5 2fc9e08f89 Dashboard Today and Waiting for you; Ctrl+K command palette (1.49.0)
Staff, supervisor and admin dashboards start with what's on today
(checked in vs places, children in now, volunteers here; money in today
and this week for supervisors) and, for supervisors and admins, what's
waiting for someone (unpaid registrations, consent, waitlists, cover
requests, roster gaps; for admins cashups, reconciling, privacy
requests, failed webhooks), each linking to where it's dealt with.
Ctrl+K opens a palette of pages, settings, reports and actions for the
role, with search: events, payments by amount, people and
registrations (admins). All campus-scoped. Bulk actions and saved
filters follow as 1.49.1 and 1.49.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:17:22 +02:00
joshua 78bcd7cc7c Merge branch 'feat/demo-seeds' 2026-10-28 14:02:56 +02:00
joshuaandClaude Opus 5.5 37731a4696 Four demo seeds; fix set-demo wiping the admin (1.48.2)
npm run demo:small | demo:events | demo:ministry | demo:multicampus
(demo:list describes them; set-demo -- --profile=<name> in backend/).
New builders: courses with sessions, attendance and certificates, a
pick-your-days conference, kids church with families, rooms and live
check-ins, volunteer rosters, a series with followers and a season
pass, and three campuses with campus-limited staff.

set-demo had failed since 1.43.0: TRUNCATE CASCADE on Campus also
emptied User (homeCampusId), deleting the admin it keeps. Tables that
User points at are now emptied with DELETE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:02:56 +02:00
joshua 4752516b15 Merge branch 'fix/privacy-notice-draft' 2026-10-28 13:15:57 +02:00
joshuaandClaude Opus 5.5 5dea712b72 Privacy notice: built-in text up to date, review flow, acceptance (1.48.1)
The built-in notice now covers volunteer rosters, event series, season
passes, webhooks, how people agree to it, and the provider-notices
storage item. Sites with their own published version see what changed
in the editor (sections marked, use new text per section or all, add
missing sections, "Done - hide this"). Account activation asks for
agreement to the terms and notice; anyone who never accepted is asked
once after login. The cookie button shows above any cookie section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:15:57 +02:00
joshua f1714f9286 Merge branch 'feat/webhooks' 2026-10-28 13:00:59 +02:00
joshuaandClaude Opus 5.5 c0d01ce993 Outbound webhooks (1.48.0)
Site settings -> Webhooks: admins add https endpoints and choose event
types (registration created/paid/cancelled, payment received/refunded,
ticket checked in, account created, event published). Events are
noticed by a Prisma client extension so every write path is covered,
re-checked before sending, and delivered as signed JSON
(X-CrossEvents-Signature, HMAC-SHA256) with retries, a 30-day delivery
log, Send test and Resend. Public https addresses only; each endpoint
is added to the operator register; kids and volunteer tickets are never
sent. New `webhooks` job. Migration 20261028160000_webhooks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:00:59 +02:00
joshua 0414854dec Merge branch 'fix/update-request-newer' 2026-10-28 12:16:56 +02:00
joshuaandClaude Opus 5.5 dd537a1a16 Fix: update request for an older version than the latest (1.47.4)
After asking for an update, a newer version coming out made the Licence
page claim the newer version was requested, with no way to ask for it.
Now it shows the version asked for, that a newer one is out, and "Ask
for X instead"; the banner shows again. Hub 1.5.0 moves the open request
on (`changed`); with an older Hub the site withdraws and asks again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:16:56 +02:00
joshua 2240c8a703 Merge branch 'feat/season-pass' 2026-10-28 11:19:58 +02:00
joshuaandClaude Opus 5.5 a7c8148588 Season passes (1.47.3)
A pass sells a ticket at each of several events in a series for one
price. Supervisors set passes up on the Event series page (events and
ticket per event; fixed once sold). Buying on the series page registers
the buyer at every event at once with one checkout (free passes give
tickets straight away; Pay now if online payment isn't possible). The
price is split over the events in proportion to their normal prices:
each registration line is priced at its share and the webhook records
one Payment per event, so cashups stay per event. Pass registrations
aren't repriced, take no coupons and can't be edited. Migration
20261028150000_season_pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:19:58 +02:00
joshua 96d1e67b85 Merge branch 'feat/series-reports' 2026-10-28 10:57:04 +02:00
joshuaandClaude Opus 5.5 e5d615e398 Series: year on year report (1.47.2)
New report (Reports -> Other, or "Compare" on the Event series page):
each event in a series side by side, oldest first - registrations,
tickets, income (dashboard definition), people checked in (average per
session on multi-session events) and attendance rate, with the change
from the event before. Exports like the other reports; campus-scoped
staff see only their campuses' events. GET /api/series/:id/compare.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:57:04 +02:00
joshua 0253a1d5fa Merge branch 'feat/event-series' 2026-10-28 10:29:59 +02:00
joshuaandClaude Opus 5.5 740897635f Event series (1.47.1)
Group separate events (Camp 2025, Camp 2026) on a series page at
/series/<slug> with upcoming and past events. Events join a series from
the event editor (copies keep it); a series can span campuses. Event
pages show "Part of ..." and "Next in this series". Signed-in people can
follow a series; the new `series` job emails followers once when a new
event in it goes live, as News & announcements (opted-in only, with
unsubscribe). Follows are in the data export and removed on
anonymising. Migration 20261028140000_event_series.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:29:59 +02:00
joshua 7ae0b6d23b Merge branch 'feat/event-clone' 2026-10-28 10:01:18 +02:00
421 changed files with 138589 additions and 14032 deletions
+882
View File
@@ -7,6 +7,888 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [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
- **Licence read-only mode works again.** The read-only guard (`src/hub/readOnly.js`) only listed
v1 paths, and every screen has used `/api/v2` since 1.51.15 — so a suspended, revoked or
unlicensed site could still create and change events, take registrations and start payments.
The v2 successors of every blocked v1 route are now blocked too (answered as `READ_ONLY` with
reason `LICENCE_READ_ONLY`). A test checks each blocked v2 path is a real route in the OpenAPI
document, so a renamed route can't slip out of the list.
## [1.52.2] - 2026-11-02
### Changed
- **`scripts/deploy.sh` stops before touching anything when files in the install folder were
changed or added by hand**, and lists them. A setup wizard run on one server had added files to
`backend/src` that the backend build picked up, so the deploy failed halfway and rolled back.
Ignored files (`.env`, uploads, backups, `ecosystem.config.js`) never count.
`ALLOW_LOCAL_CHANGES=1` deploys anyway.
- `bash scripts/deploy.sh --check` runs only that check (no backup, fetch, build or reload). The
script can also be run from a copy outside the install (e.g. `/tmp/deploy.sh`, from the folder
you're in), so an older install can check before updating — see DEPLOYMENT.md, "Updating".
## [1.52.1] - 2026-11-02
### Changed
- **The API documentation (`/docs/v2`, `GET /api/v2/openapi.json`) now describes every route in
full.** Before, most routes answered by a v1 handler showed an empty body and `data: unknown`.
Each one now documents the body it reads and what it answers, from the handler's actual code, and
every path and query parameter has a description. Actions that take no input say "No body
needed".
- How: `documented: { body?, response? }` on `v1Route` (and `defineRoute`), with shared shapes in
`src/v2/docs.ts`. These are documentation only — requests and answers aren't checked against
them, so nothing an app already does can start failing — but outside production a body or
answer that doesn't fit is logged (`[api/v2] … doesn't match its documented schema`).
`tests/v2Docs.test.ts` keeps the document complete.
- The funnel report's `eventIds` and the webhook deliveries' `status`/`before` filters are now
declared (they worked, but weren't in the document or the typed frontend calls).
- The frontend's generated API types are richer; a few calls had their types tightened to match
(ticket transfer, breach register, kids check-out). Nothing changes in what they send.
## [1.52.0] - 2026-11-02 — urgent
### Fixed
- **Paid door and manual registrations no longer end up without tickets.** On an event with a
required attendee form, tickets wait until a form is in for each person — but At the door and
Manual registration never asked for the form, so payment was recorded and nobody could be checked
in. The form now pops up when someone is registered, selected or pays at the door (and after
registering or paying in Manual registration): fill it in, or **Fill in later**, which issues the
tickets once it's paid and sends the payer a link to the form (`POST
/api/v2/registrations/{id}/form-later`, staff; `Registration.formWaivedAt`). Closing the popup
counts as Fill in later. The door's check-in panel says when a form is holding tickets back.
- **Picked-session bookings needed a form per session.** A person booked on three sessions needed
three forms before their tickets were issued; each person now needs one (`formPlaces` in
`utils/ticketUtils.js`).
- **A required form with only statements and headings held tickets back for ever** (nothing to
answer). Only a required form with a question counts now (`formNeedsAnswers`). Every place that
decided this on its own (coupon making it free, manual and online registration) now asks the
ticket generator (`formsOutstanding`).
- **Self-service kiosk:** it asked for one form per main ticket picked, so a visitor adding to an
existing registration (or booking picked sessions) could finish with the forms still short and
no tickets. It now asks for the forms the server says are missing (`formsNeeded` on `POST
/api/v2/registrations/manual`). Headings on the form showed as an empty text box; they're shown
as text now, with the statements.
- Recording a payment answers `formsNeeded` when a form still holds the tickets back.
### Added
- **Corrections** (admins, `/dashboard/admin/corrections`): every registration at an event —
past and closed ones too — with what needs putting right. Issue all missing tickets at once
(forms left for later); fill in a form; issue tickets before payment ("pay later", the balance
is still owed — `Registration.ticketsReleasedAt`); make it free (the balance is written off as a
complimentary discount, `Registration.complimentaryDiscount`, part of `discountTotal`, shown as
"Complimentary" on invoices and receipts; not on closed events); and check a ticket in or undo it
after the event, for any session (a manual check-in, no message sent). Sending the tickets is a
choice. `/api/v2/corrections/…` (admin): `GET events/{id}`, `POST events/{id}/issue-missing`,
`POST registrations/{id}/tickets`, `POST registrations/{id}/complimentary`,
`POST tickets/{id}/check-in`. Code in `src/services/corrections.ts`.
- Audit actions `attendee_form_waived`, `tickets_issued_override`, `registration_complimentary`
and `checkin_corrected`. Migration `20261102120000_corrections`.
### Upgrading
- Update before your next event with a required attendee form. After updating, open Admin →
Corrections for a past event to issue any tickets that went missing (leave "Send them" off if
the event is over).
## [1.51.17] - 2026-10-30 — security release
### Security
- **Forgot password no longer reveals whether an account exists.** It answered "Email not found"
(404) for an unknown address, so anyone could check which emails have accounts. It now gives
the same answer and status for every address, sent before any work that only happens for a
real account (so the response time doesn't tell either); the reset link is made and sent
afterwards. Applies to `POST /api/users/forgot` and `POST /api/v2/auth/password/forgot`.
- **Sign-in no longer reveals it either.** An unknown email answered "User does not exist" (with a
different status on v1); it now gets exactly the answer a wrong password gets, after a password
check against a stand-in hash so it takes as long.
- Forgot password is rate-limited like sign-in, so it can't be used to flood someone's inbox with
reset emails.
### Upgrading
- Update as soon as you can. Nothing else changes; the forgot-password page shows the new message.
## [1.51.16] - 2026-10-30
### Added
- **Incoming webhooks on v2:** `/api/v2/webhooks/yoco`, `/payfast` and `/whatsapp`. New provider
set-ups use them — the PayFast notify URL (every new checkout), the Yoco webhook URL offered in
Site settings → Payments, and the WhatsApp webhook URL. The v1 addresses (`/api/webhooks/…`,
`/api/whatsapp/webhook`) keep working with the same handlers, because a provider may not follow
a redirect on a POST; they now name their v2 address in a `Link` header.
- **Links in sent messages on v2:** "Add to calendar" links point at
`/api/v2/events/{id}/calendar.ics`, and the one-click unsubscribe header at
`/api/v2/unsubscribe/{token}/one-click`. The same links in messages already sent answer `308`
with their v2 address; a mail app's one-click POST to the old address is still handled there.
### Fixed
- `/api/webhooks/whatsapp` was an empty stub that answered 500; it now runs the WhatsApp webhook
handler like `/api/whatsapp/webhook`.
### Upgrading
- Nothing has to change: existing Yoco and WhatsApp webhook registrations keep working. To move a
registration to its v2 address, generate the Yoco webhook again (Site settings → Payments → Yoco)
and paste the webhook URL shown in Site settings → WhatsApp into WAWP. PayFast moves by itself.
## [1.51.15] - 2026-10-30
### Added
- **API v2 — privacy and admin:** my privacy (`/me/privacy`, news, my data as JSON or PDF, requests,
accepting the notice), `/privacy/notice`, unsubscribe links (`/unsubscribe/{token}`), the privacy
admin (requests, notice drafts and publishing, operators, consent and access logs, readiness,
retention, breaches); `/admin/audit-log`, jobs, impersonation (`/admin/impersonate/{userId}`,
`/me/impersonation` and its end and log); site settings and first-run set-up (`/settings`,
`/setup`); the Hub; outbound webhooks; backups (including the download); Yoco and PayFast settings
(`/payments/yoco/…`, `/payments/payfast/…`).
- `v1Route` handles `res.download` and `res.sendFile`.
### Changed
- **Every API call the app makes now goes to `/api/v2`.** Only the incoming webhooks (Yoco,
PayFast, WhatsApp) and the links in sent messages are still v1 — those are 1.51.16.
- Ending a viewing-as session works on v2 while the session is read-only.
## [1.51.14] - 2026-10-29
### Added
- **API v2 — people and messaging:** the rest of accounts (`/auth/login/code/resend`,
`/auth/password/forgot` and `/reset`, `/auth/activate`, `/me/sessions/revoke`, `/me/close`,
`/me/activity`, `/me/verify-password`, `/me/two-factor/…`; admin `/users`, `/users/{id}`,
`/users/exists`, sessions and anonymising); `/event-listing` (the full public list the site's event
pages show) and `/admin/events`; messaging an event's people, email and WhatsApp broadcasts,
`/scheduled-messages`, `/automations/schedule`; the WhatsApp connection (`/whatsapp/…`); `/banner`;
`/saved-filters`; `/dashboard/today` and `/search`; `/stats/…`; `/analytics/…`; report files
(`/reports/pdf`, `/excel`, `/email`, `/whatsapp`).
- `defineRoute` refuses a method and path defined twice (the first would silently win).
- `GET /me` also answers `newsPromptPending`, `noticePromptPending`, `twoFactorRequired` and
`campusScope`, so the app's session (and its after-sign-in prompts) comes from v2.
### Changed
- Sign-in (including the self-service kiosk's), password reset, two-step sign-in, account
settings, admin users, messaging, broadcasts, WhatsApp set-up, dashboards, saved filters,
reports and the public event lists use v2.
## [1.51.13] - 2026-10-29
### Added
- **API v2 — kids check-in and volunteers:** my family (`/me/family`, its children and adults); kids
set-up and the check-in desk (`/events/{id}/kids/…`: settings, board, families, check-in and out,
collection codes, duty, offline snapshot and sync; `/kids/check-ins/{id}/…`, `/kids/alerts/{id}/resolve`);
serving (`/me/volunteering`, `/events/{id}/volunteer-roles`, sign-up, swaps, self check-in) and rosters
(`/events/{id}/volunteers` and its QR and settings, roles, assigning, manual check-in, hours).
### Changed
- The kids desk, rooms view, family page, serving page and volunteer rosters use v2.
## [1.51.12] - 2026-10-29
### Added
- **API v2 — tickets and attendance:** `/me/tickets` (+ `/email`), `/tickets` (the staff list, one
ticket, sending, issuing, scan stats and recent scans, the ticket for a QR code), transfers
(`/tickets/{id}/transfer`, `/ticket-transfers/{token}` to accept or decline), `/events/{id}/tickets`;
offline scanning (`/scan-locks`: take tickets offline, snapshot, sync, release, reports); the
attendance register (`/events/{id}/attendance`, its PDF/Excel export) and certificates (issue,
send, revoke, download, preview, verify, `/me/certificates`); allocations (`/events/{id}/allocations`,
`/allocation-sets`, `/allocation-groups`, `/me/allocation-groups`).
- Frontend: `apiV2Raw()` for screens whose helpers pass the path as a string; file downloads
read v2's error shape.
- `v1Route` can run a v1 router (with `map: () => ({ url })`); a chain that answers nothing is a
404 instead of waiting.
### Changed
- Tickets, transfers, offline scanning, attendance, certificates, allocations and the kids room
set-up use v2.
## [1.51.11] - 2026-10-29
### Added
- **API v2 — money:** payments (`/payments`, `/payments/{id}`, receipts, refunds, donations
assigned to registrations, stats, providers and their settings, open checkouts), payment links
(`/registrations/{id}/payment-link` (+ `/send`), `/payment-links/{id}` — the pay page),
`/donations/checkout`, `/me/payments`; cash-ups (`/events/{id}/cashup` and its counts, draft, close
and reopen, `/cashup-lines/{id}/pending`, `/cashups/audit`); provider transactions; the waitlist
(join, mine, offers by link, the event's list, offer/move/remove); sponsorship; parental consent
(the event's list, medical notes, reminders, the consent form by link); `/form-responses`; and
feedback (set-up, send now, the summary, the form by link).
- **`src/v2/viaV1.ts`:** `v1Route()` — a v2 route answered by the v1 handler, in-process, behind
v2's access checks, validation and docs. v1 JSON becomes `data`; v1 errors become v2 errors with
v1's `code` as `reason`; piped files become downloads.
- The v2 reference (`/docs/v2`) opens with its sections collapsed and has a filter box — with
more than about 130 endpoints Swagger UI stopped drawing the expanded list.
### Changed
- Every money screen (payments, at the door, cash-up, consent, waitlist, feedback, forms, the pay
and donate pages, the user dashboard) uses v2.
## [1.51.10] - 2026-10-29
### Added
- **API v2 — managing registrations:** the staff list (`GET /registrations`, paged, `unpaid`,
`userId`), an event's registrations (`/events/{id}/registrations`), a registration in full
(`/registrations/{id}/full`, `/me/registrations/full`), `PATCH /registrations/{id}/status`,
registering someone (`POST /registrations/manual`), bulk actions (`/registrations/bulk`), changing
items (`PUT /registrations/{id}/items`), the invoice, coupon codes on a registration, named
attendees and their form links, attendee form drafts and responses
(`/registrations/{id}/form-draft`, `/registrations/{id}/form-responses`).
- **`src/services/registrationAdmin.ts`:** everything after a registration is made, moved from
`registrationController.js` (now about 60 lines of v1 wrappers).
- v2 manual registration accepts the same `notificationPreference` and `couponCode` values v1 did.
- Frontend: `fetchAllRegistrations()` reads every page of the staff list. Registrations, at the
door, manual registration, self-service, payments, forms, the attendee composers, reports and
the user dashboard all use v2.
### Changed
- The roadmap gives money (payments, cashup, provider transactions, waitlist, sponsorship,
consent, feedback) its own release, 1.51.11; the later areas move up one, and incoming webhooks
and links are now 1.51.16.
## [1.51.9] - 2026-10-29
### Added
- **API v2 — series, season passes, deals and campuses:** `/series` (list, create, `PATCH`, delete,
`/series/by-slug/{slug}`, `/series/{id}/compare`, `PUT`/`DELETE /series/{id}/follow`);
`/season-passes` (list, create, `PATCH`, delete, `/season-passes/{id}/buy`),
`/season-pass-purchases/{id}/checkout`, `/me/season-passes`; bundle deals
(`/events/{id}/bundles`, `PATCH`/`DELETE /bundles/{id}`, `/bundles/quote`); coupon codes (`/coupons`,
`/coupons/{id}`, `/coupons/redemptions`, `/coupons/check` — always a 200 with `valid`); campuses
(`/campuses`, `/campuses/admin`, `/campuses/by-slug/{slug}`, `PUT /campuses/enabled`,
`PUT /campuses/order`, create, `PATCH`, delete, `/campuses/{id}/audience`).
- **Services:** `src/services/series.ts`, `deals.ts` and `campuses.ts` — the series, season pass,
bundle, coupon and campus controllers are now thin v1 wrappers.
- **v2 errors carry a service's own code as `reason`** (e.g. `CAMPUS_HAS_EVENTS`), like the shared
middleware's codes.
- Frontend: `apiV2Data()` — `apiV2` for screens with their own record types (path, parameters and
body still checked; cast the answer). Series, season passes, bundles, coupons, campuses, the
coupon field and bundle quotes on the register page all use v2.
### Changed
- Checking a coupon code on v2 answers `valid: false` with the reason instead of an error; the
coupon field shows that reason as before.
## [1.51.8] - 2026-10-29
### Added
- **API v2 — the rest of setting events up:** `GET /events/{id}/full` (the event in full — what's
left per ticket, variant and session, form, attachments, bundles, next in series; staff also see
inactive events, creator and email recipients), `GET /events/{id}/calendar.ics`,
`GET /events/by-alias/{alias}`; ticket options (`POST /events/{id}/ticket-options`,
`PATCH`/`DELETE /ticket-options/{id}`) and variants (`POST /ticket-options/{id}/variants`,
`PATCH`/`DELETE /variants/{id}`); attachments (`GET`/`POST /events/{id}/attachments` — multipart —
and `DELETE …/{attachmentId}`, admin `/attachments/status` and `/attachments/sync`); scanning
sections (`/sections`); planned costs (`/events/{id}/costs`, `/costs/{id}`); image uploads
(`/uploads/event-image`, `/uploads/logo`, `/uploads/favicon` — returning `path` and `url`).
- **Services:** `src/services/eventDetails.ts` (from eventController.js, now 1,080 lines, from 2,217
at the start of 1.51.7) and `src/services/setup.ts` (sections and costs). v1 uses them too.
- **v2 route features:** `file` routes answer with a download (`fileResponse`), and `upload` routes
are described as multipart in the OpenAPI document.
- Frontend: `fetchEvent()` for every event page (the public event and registration pages too),
and `lib/eventSetup.ts` for ticket options, sections, attachments, costs and image uploads.
### Changed
- The pre-migration fallbacks for attachments (JSON manifests on disk) are gone from the normal
path; old manifests can still be imported with `/attachments/sync`.
- v1's `GET /api/events/:id` now points at `/api/v2/events/{id}/full` as its successor.
- Roadmap: series, season passes, bundles, coupons and campuses are 1.51.9; later areas move up one
(to 1.51.15).
## [1.51.7] - 2026-10-29
### Added
- **API v2 — setting events up:** `GET /events/manage` (every event staff may see, hidden ones too;
`past`, `inactive`, `closed`; campus-scoped), `POST /events`, `PATCH /events/{id}` (only what's sent
changes; `sessionChanges` when sessions were sent), `DELETE /events/{id}` (admins; deactivates),
`POST /events/{id}/copy-from`, and `GET`/`PUT /events/{id}/notify-recipients`. Event records are
typed for their core fields and pass the rest through as stored (`z.looseObject`).
- **`src/services/events.ts`:** create, update, deactivate, the staff list, notify recipients and
copy-from moved out of `eventController.js` (2,217 → 1,717 lines), with the schedule helpers
(`withSchedule`, `scheduleData`, `saveSchedule`). v1 uses it too. The pre-migration fallbacks
(retrying without `registrationDeadline`, `goLiveAt`…) are gone — every install has those columns.
Creating an event now also returns its ticket options.
- Frontend: `fetchManagedEvents()` (`lib/api.ts`) — every event picker, the kiosk, reports and the
set-up screens load events through v2; the event wizard creates, saves, copies and sets
notification recipients through v2. v1 deprecation links for these endpoints.
### Changed
- **pg-boss 11** (servers are on Node 24). It can't upgrade pg-boss 10's tables, so the job queue
uses a new schema, `pgboss_v11`; schedules are recreated at start, and the admin page's
"recent runs" start again from this release. The old `pgboss` schema is left as it was (it can
be dropped: `DROP SCHEMA pgboss CASCADE;`). pg-boss 11 brings cron-parser 5, so the last
cron-parser 4 deprecation is gone.
- Roadmap: the rest of the events area (event details for staff, ticket options and variants,
attachments, sections, costs, series, season passes, bundles, coupons, campuses, calendar file,
alias) is 1.51.8; the later areas move up one (to 1.51.14).
## [1.51.6] - 2026-10-29
### Fixed
- **Command palette (Ctrl+K) was missing things.** It listed 9 of the 19 reports — now every
report (built from the report catalogue, so new ones appear by themselves), plus Dashboard,
Reports, Ticket options and My groups, and your own pages (My events & tickets, My payments,
My privacy, My kids, My serving, My attendee forms, Make a donation). Attendee forms now opens
for staff and supervisors too (the page allows them; the palette had it as admin-only). An e2e
test fails when a dashboard page is neither in the palette nor deliberately left out.
### Changed
- **Excel exports use ExcelTS** (`@cj-tech-master/excelts` 10) instead of exceljs, which is
unmaintained: same API, no dependencies — the backend has 87 fewer packages and none of
exceljs's deprecated ones (fstream, glob 7, inflight, lodash.isequal, rimraf 2). New
`e2e/excel.spec.ts` opens a real report and attendance export. ExcelTS itself now says it's no
longer developed (its successor, documonster, is pre-1.0); existing versions keep working.
- The backend needs **Node 22 or newer** (`engines`); the servers run Node 24 LTS.
- Roadmap: incoming webhooks and links in sent messages get v2 URLs too (1.51.13), with the v1 paths
still answering; the area releases are now 1.51.7 – 1.51.13.
## [1.51.5] - 2026-10-29
### Changed
- **Next.js 16** (from 15.5) and **React 19.3**. Builds and dev use Turbopack (Next 16's default);
`tsconfig.json` `jsx` is `react-jsx` (required by Next 16); `images.domains` → `remotePatterns`.
- **ESLint 10** (ESLint 9 is out of support) with Next 16's own flat configs
(`eslint-config-next/core-web-vitals` and `/typescript`, no more FlatCompat). `npm run lint` is
`eslint src` (`next lint` was removed). Next 16's React Compiler-era rules
(`react-hooks/set-state-in-effect`, `purity`, `immutability`, `static-components`, `refs`) are
warnings for now — 181 places, mostly loading data in `useEffect`.
- Removed the empty `middleware.ts` (a pass-through that did nothing; Next 16 renames middleware to
"proxy"), which also drops its 117 kB bundle.
## [1.51.4] - 2026-10-29
### Security
- **Next.js 15.5.28** (critical: remote code execution through the image optimiser with SVGs, and a
Server Actions denial of service), **@sentry/nextjs 10.78** (critical: middleware bypass),
**zod 4.6.7** (high: memory exhaustion on oversized collections; backend and frontend),
**multer 2.5.0** (high: denial of service from a pending file filter), **nodemailer 10.3.0**
(moderate: three address-parsing flaws that could add recipients), fast-uri 3.1.9 and
ip-address 10.7.5 (moderate). Minimum versions raised in package.json so a fresh install can't
resolve an older one. `npm audit`: frontend and root clean; the backend's 6 remaining reports are
low and dev-only (jest → deepmerge), with no real fix (npm suggests downgrading Jest to 25).
### Changed
- **cron-parser 5** (v4 is unmaintained): `CronExpressionParser.parse` in `src/jobs/index.js`.
- **Node 24 LTS** for new installs (`scripts/install.sh`); Node 20 reached end of life in April 2026.
`deploy.sh` warns on servers older than Node 22; DEPLOYMENT.md has "Upgrading Node".
- Roadmap: every endpoint moves to v2 before 2.0 (1.51.5 – 1.51.11).
### Known
- Still deprecated, with reasons: exceljs (unmaintained; brings fstream, glob 7, inflight,
lodash.isequal, rimraf 2) — replacing it with ExcelTS waits for approval; ESLint 9 (out of
support) — ESLint 10 needs Next.js 16 (1.51.5); @opentelemetry/sdk-trace-base — pulled in by the
latest @sentry/nextjs, for Sentry to update; glob 10 — inside Jest (dev only).
## [1.51.3] - 2026-10-29
### Changed
- **Express 5** (from 4). Kept Express 4 behaviour where it changed: the `qs` ("extended") query
parser, and `req.body` as `{}` when a request has no JSON body. `@types/express` 5.
- **The ticket scanner and at-the-door check-in use API v2** (`/api/v2/checkin/{qrCode}`). The v2
look-up now gives `attendee`, `bookedBy` and `ticketOptionId` (for section checks).
- **Shared middleware answers in the v2 shape on /api/v2:** rate limits, the impersonation and
licence read-only guards, the auth checks and campus refusals write v1's `{ message, code }`;
on v2 that becomes `{ error: { code, message, reason } }` (`reason` = the specific old code, e.g.
`TWO_FACTOR_REQUIRED`, `LICENCE_READ_ONLY`). Read-only answers have the v2 code `READ_ONLY` (403).
The frontend's `apiFetch` treats `reason` like v1's `code`, so the 2FA and impersonation
handling works on v2 calls too.
### Added
- **v1 deprecation headers:** v1 endpoints that have a v2 replacement answer with
`Deprecation: @<date>` (RFC 9745) and `Link: <…>; rel="successor-version"` (`src/v2/deprecation.ts`).
The rest of v1 has no replacement yet, so no header.
## [1.51.2] - 2026-10-29
### Added
- **API v2 accounts:** `POST /auth/signup` (needs `acceptTerms`), `POST /auth/login` (answers
`signed_in` with a token, or `code_required` for two-factor) and `POST /auth/login/code`, both
behind the login rate limit; `PATCH /me` (only what is sent changes; a new password needs the
current one) and `GET /me` now with `notificationPreference` and `homeCampusId`.
- **API v2 check-in:** `GET /checkin/{qrCode}` (look up: holder, what's left, session, consent)
and `POST /checkin/{qrCode}` (check in everyone left or `people`). Every normal result is a 200
with an `outcome` — `checked_in`, `already_used`, `consent_required`, `wrong_event`,
`wrong_section`, `section_not_found`, `no_session`, `locked`, `transferred`. Staff only, with the
same campus rule as v1.
- **Services:** sign up, log in, the 2FA step and profile changes moved from `userController.js`
(~430 lines shorter) to `src/services/auth.ts`; scan preview and scanning from
`ticketController.js` to `src/services/checkin.ts`. v1 answers exactly as before (`ServiceError`
can carry the old `v1Status`, e.g. 400 for a wrong password, which is 401 in v2).
- v2 routes can take extra middleware (`middleware: [loginLimiter]`).
### Changed
- Logging in (and the 2FA code step), signing up, and changing your details or password use API v2.
The scanner stays on v1 until 1.51.3.
## [1.51.1] - 2026-10-29
### Added
- **API v2 registrations and payments:** `POST /registrations` (signed in, guest for free
events, or a waitlist link), `GET /registrations/{id}`, `POST /registrations/{id}/cancel` and
`POST /registrations/{id}/checkout` (online payment, with an optional donation; answers
`priceUpdated` with the new balance when an early-bird price ran out). Owners only (admins too);
someone else's registration is `NOT_FOUND`.
- **Services in TypeScript:** making and cancelling registrations moved from
`registrationController.js` to `src/services/registrations.ts`, and paying a registration
online from `paymentController.js` to `src/services/payments.ts`. v1 and v2 run the same code;
`ServiceError` carries the HTTP status. `registrationController.js` is ~560 lines shorter.
- **Typed frontend calls:** `npm run api:types` (repo root) writes the OpenAPI document to
`frontend/src/lib/api-v2/openapi.json` and generates `schema.d.ts` from it (openapi-typescript);
`apiV2(method, path, { params, query, body, authToken })` in `frontend/src/lib/apiV2.ts` checks
the path, parameters, body and answer. A backend test fails while the saved document is stale.
### Changed
- The register page, "Pay now" (registration success page and My Events) and cancelling on My
Events use API v2. `apiFetch` reads v2 errors (`error.message`, with `code`/`fields` in `data`).
## [1.51.0] - 2026-10-29
### Added
- **API v2** (`/api/v2`), the groundwork for 2.0: every endpoint is declared once with zod
schemas (`src/v2/route.ts`). The declaration validates the request, strips the response to what
the schema lists, and generates the **OpenAPI 3.1** document (`GET /api/v2/openapi.json`, admin)
shown at **`/docs/v2`** (Swagger UI, served locally). One error shape
`{ error: { code, message, fields? } }` with stable codes, and one list shape
`{ data, meta: { page, limit, total, pages } }`. First endpoints (read-only): `GET /events`,
`GET /events/:id`, `GET /me`, `GET /me/registrations`. v1 (`/api/…`) is unchanged.
- **TypeScript in the backend:** `tsconfig.json` (`allowJs`; `.ts` strict), `npm run build` →
`dist/`, `npm run typecheck`, `npm run dev` through tsx, Jest runs `.ts` via ts-jest. Typed
doors into JS modules in `src/v2/legacy.ts`.
- Tests: `tests/v2.test.ts`, e2e `e2e/api-v2.spec.ts`.
### Changed
- The server moved from `backend/src/index.js` to `backend/src/app.js`. `src/index.js` is now a
launcher that runs the matching build in `dist/` (or compiles on the fly with tsx when there is
none), so existing PM2 configs keep working unchanged.
- `scripts/deploy.sh` and `scripts/install.sh` build the backend (`npm ci --include=dev`, then
`npm run build`); a rollback to a release before 1.51.0 skips the build. The first update to
1.51.0 by an older deploy script still starts (it compiles at start-up); the next update builds.
- e2e starts the backend with tsx.
## [1.50.2] - 2026-10-29
### Added
- **Searchable event picker** (`components/shared/EventPicker.tsx`) instead of the plain event
dropdowns on staff and supervisor pages (scanner, event tickets, at the door, payments, manual,
email and WhatsApp attendees, forms, attendance, consent, waitlist, allocations, bundles, offline
scans, kids and volunteer set-up, manage registrations, the kiosk). It opens on upcoming events
(soonest first); past ones are behind **Show past events** (latest first), and typing searches
every event by name, campus or date. Keyboard: arrows, Page Up/Down, Enter, Escape.
- **Reports:** the events list can be searched; "Select these N" / "Clear these" act on the matches.
- **Site settings → Webhooks → How webhooks work:** what is sent and when, the envelope and
headers, retries and duplicates, checking the signature (Node.js and PHP), Zapier/Make set-up,
and a copyable example of every event type. `GET /api/outbound-webhooks` event types now carry
an `example`; `tests/webhooks.test.js` checks each one has the same shape as the real payload.
### Changed
- Pages that listed every event ever made in their dropdown (email and WhatsApp attendees, forms,
bundles, scanner, payments) now open on upcoming ones; their separate "Include past events"
checkboxes are gone (the picker has its own). Event tickets and Payments can now reach past
events too.
- e2e: `pickEvent()` in `e2e/fixtures.ts`; `e2e/event-picker.spec.ts`.
## [1.50.1] - 2026-10-29
### Added
- **Audit log:** search by person (name or email), target (type or `#id`) or IP, a Role filter,
**Clear filters**, and "21–40 of 312" under the list. The Action filter now lists every action
that can be logged (it was missing 35 — campuses, kids check-in, volunteers, series, season
passes, webhooks, bulk actions, Hub updates, certificates and more), sorted by name.
- **Data requests:** search by name or email, and closed requests a page (50) at a time — the list
used to stop silently at 200.
- **Webhook delivery log:** filter by status (failed, retrying, succeeded) and **Load older** —
it used to show only the latest 50.
- API filters: `/api/registrations?unpaid=1&userId=`, `/api/payments?userId=&from=&donations=1`,
`/api/privacy/requests?search=&page=&limit=`, `/api/outbound-webhooks/:id/deliveries?before=`,
`/api/admin/audit-log?search=&role=`. Shared `components/shared/Pager.tsx`.
### Fixed
- Audit log: the **To** date left out that day's entries (it meant midnight at the start of the
day). Both dates are now whole days in the site's time zone.
### Changed
- **Payments** (supervisor) no longer downloads every payment and registration ever made when it
opens: it loads the latest payments, today's (for the totals), donations with what they were
applied to, and only registrations still owing. Refunds load the chosen person's own payments and
registrations when they're picked. Faster as the years add up; the page works the same.
## [1.50.0] - 2026-10-28
### Added
- **Accessibility pass (WCAG 2.2 AA)** on the public pages, sign-in/sign-up, registration,
payment, My Events and the self-service kiosk:
- **Keyboard:** a "Skip to main content" link as the first Tab stop on every page; a clear focus
ring (in the brand colour) on everything you can tab to; popups (Contact us, Join the waitlist,
Sponsor a spot, Transfer ticket, the registration popup and send dialogs on My Events, the kiosk's
Change Event and Confirm account changes) are announced as dialogs, take focus when they open,
keep Tab inside, close with Escape and hand focus back. The kiosk's "Create an account" switch
and its password eye can now be used with the keyboard.
- **Screen readers:** every form field on those pages has a label tied to it (sign-in, sign-up,
forgot/reset password, activation, profile, privacy centre, payments filters, donate, forms,
feedback, consent, waitlist, sponsorship, transfer, kiosk and its custom questions); ticket
quantity buttons say what they do ("Add one Adult", "Remove one Adult") and the number is
announced; option buttons say which one is chosen; error messages are announced when they
appear.
- **Colour:** light grey text darkened to meet 4.5:1, and links inside sentences are underlined
so they don't rely on colour alone. The page honours "reduce motion" on the device.
- **Branding:** Site settings → Branding (and the setup wizard) warn when the primary colour is
too light for white text and links (under 4.5:1) and offer a darker shade of it in one click.
- Automated accessibility checks: `e2e/accessibility.spec.ts` runs axe-core (WCAG 2.0–2.2 A/AA
rules) on those pages, plus keyboard tests (skip link, popup focus and Escape, ticket quantities,
the kiosk switch). New dev dependency `@axe-core/playwright`.
- `components/shared/ModalOverlay.tsx` for the app's own popups, and `components/layout/SkipLink.tsx`.
### Changed
- e2e tests click ticket quantities by name (`/^Add one /`) instead of "+".
## [1.49.2] - 2026-10-28
### Added
- **Saved filters** on Manage registrations, Users, Events and Reports: set the filters, then
**Saved filters → Save** under a name; click one to apply it; **★** makes it the default, applied
when the page opens (but not over a link that already says what to show, such as the
dashboard's "Waiting for you"). Reports remember the report, events, dates, campus and the
past/inactive/closed options. Per person, stored on the server (up to 30 per page), removed when
an account is anonymised.
- `/api/saved-filters` (list, save, rename/default, delete — your own only). Migration
`20261028180000_saved_filters`.
## [1.49.1] - 2026-10-28
### Added
- **Bulk actions on registrations** (Admin → Manage registrations): tick registrations, or "Select
all shown" after filtering, and act on them at once —
- **Send a message** by email or WhatsApp, as an event update to the person who registered
(placeholders as on Email attendees);
- **Mark paid** — records what each still owes, by cash, EFT, card or other; paid registrations get
their tickets by email, as with a single payment;
- **Move to another ticket** (one event) — the same lines, issued tickets and QR codes move with
them, repriced at the new ticket's current price, so the difference shows as owing or overpaid;
places on the new ticket are checked first;
- **Put in a room or group** (one event) — their named people, with the group's capacity and
gender rules;
- **Export to Excel** (recorded in the access log, like other exports);
- **Cancel** — coupon uses released and the waitlist offered the places; no refund (record refunds
on the Payments page).
Each runs the same code as doing it one at a time (closed events, payment caps, coupons,
waitlist, privacy rules on messages), with a result per registration. Up to 500 at a time.
- `POST /api/registrations/bulk` (campus-checked for every registration; payments and ticket moves
refused in licence read-only mode); `registrationIds` filter on the email and WhatsApp attendee
endpoints. Audit action `registrations_bulk` (migration `20261028170000_registrations_bulk`).
## [1.49.0] - 2026-10-28
### Added
- **Today on the dashboard** (staff, supervisors, admins): what's on today — each event, or its
session today — with people checked in so far against places, children in now at kids church,
and volunteers here against rostered (and how many short). Supervisors and admins also see money
in today and over the last 7 days. Refreshes every 30 seconds; follows campus scoping.
- **Waiting for you** (supervisors, admins): what needs someone, each linking to where it's dealt
with — unpaid registrations for events in the next two weeks, young people waiting for parental
consent, waitlists, cover requests, serving roles in the next week short of people, and for
admins past events to cash up, provider payments to reconcile, open privacy requests and failed
webhook deliveries.
- **Command palette — Ctrl K (⌘K)** anywhere in the dashboard, or "Search or jump to…" on the
dashboards: pages, settings tabs and reports for the person's role, actions ("New event", "Scan
tickets", "Record a payment"…), and search — events for everyone; payments by amount ("R450") for
supervisors; people and registrations (by reference) for admins. Keyboard: ↑/↓, Enter, Esc.
- Deep links: `/dashboard/supervisor/events?edit=<id>` and `?new=1`; `/dashboard/admin/registrations?q=&status=&event=`;
`/dashboard/admin/users?q=`.
- `GET /api/dashboard/today`, `GET /api/dashboard/search?q=`.
## [1.48.2] - 2026-10-28
### Added
- **Four demo seeds**, to match the church a demo is for — `npm run demo:small`, `demo:events`
(the original demo), `demo:ministry` and `demo:multicampus` from the repo root (`demo:list`
describes them; in `backend/` it's `npm run set-demo -- --profile=<name>`). New in the seeds:
multi-session courses with attendance and certificates, a "pick your days" conference, kids church
with families, children, rooms and check-ins (including some checked in right now), volunteer
rosters with QR check-ins and a cover request, an event series with followers and a season pass,
and three campuses with campus-limited staff. Campuses are switched on only for `multicampus`.
Series announcements and volunteer reminders are marked as sent, so the jobs don't email demo
people. See `backend/DEMO_DATA.md`.
### Fixed
- **`npm run set-demo` failed since 1.43.0** ("Event_createdById_fkey"): clearing the `Campus` table
with TRUNCATE … CASCADE also emptied `User` (it points at Campus for the home campus), deleting the
admin it keeps. Tables that `User` points at are now emptied with DELETE after the admin's links
to them are cleared.
## [1.48.1] - 2026-10-28
### Changed
- **Built-in privacy notice brought up to date** with volunteer rosters (what is kept, reminders,
who sees the roster and cover requests), following an event series, season passes, webhooks
(connected systems, what they receive, 30-day copies), how people agree to the notice, and the
`cross_events_dismissed_notices` browser-storage item. Sites that haven't published their own
version show the new text straight away.
- **Sites that published their own version** are now told: Admin → Privacy → Privacy notice lists
what changed in the built-in text since they last reviewed it, marks each section with new text
(**show it** / **Use the new text**), offers **Use all the new text**, and **Add** for built-in
sections the notice doesn't have. **Done — hide this** records the review
(`privacy_notice_builtin_reviewed`). Their own text is never changed without them; publish (with
"Significant change" if people should accept again) to make it live.
### Fixed
- **Agreeing to the notice when an account was made for you** — activating an account (made by
staff, at the door or by whoever registered you) now asks you to agree to the Terms of Use and
Privacy Policy, as sign-up does (consent log source "Account activation").
- **Never accepted?** Anyone who has never accepted any version (accounts made for them, or from
before acceptances were recorded) is asked once after login ("Our privacy notice"), even while
the built-in notice is in use. Before, they were only asked after a version marked as a
significant change was published.
- The "Change my cookie choice" button on the privacy page also shows above a cookie section an
admin wrote themselves (any section whose id mentions cookies), not only the built-in one.
## [1.48.0] - 2026-10-28
### Added
- **Outbound webhooks** — Site settings → **Webhooks**: send what happens here to other systems
(ChurchSuite, Planning Center, Google Sheets through Zapier or Make…). Add an https address and
choose what to send: `registration.created`, `registration.paid`, `registration.cancelled`,
`payment.received`, `payment.refunded`, `ticket.checked_in`, `user.created`, `event.published`.
- Each message is JSON `{ id, type, createdAt, data }` signed with the webhook's own secret
(`X-CrossEvents-Signature: t=…,v1=…`, HMAC-SHA256 of `<t>.<body>`; shown once, "New secret" to
rotate). **Send test**, a **delivery log** (30 days, with the full message and what came back) and
**Resend**. Failures are retried after 1 min, 5 min, 30 min, 2 h and 12 h, then marked failed.
- Events are noticed in one place (a Prisma client extension in `config/db.js`), so every way of
registering, paying, cancelling, refunding or checking in is covered; each is checked again a
moment later, so a change that was rolled back sends nothing.
- Safety and privacy: https addresses on public networks only (checked again at each send;
redirects aren't followed); each webhook is added to the POPIA operator register (marked inactive
when deleted); kids check-in and volunteer tickets are never sent; deliveries are deleted after 30
days and with an anonymised account.
- New `webhooks` job (every minute, `WEBHOOKS_JOBS_ENABLED`); `WEBHOOKS_ENABLED=false` stops
noticing events. Audit action `webhook_endpoint_changed` (migration `20261028160000_webhooks`).
## [1.47.4] - 2026-10-28
### Fixed
- **Update requested, then a newer version came out** — the Licence page said the newer version
was requested ("Version 1.47.3 is available. Update requested …") when the request was for an
older one, and there was no way to ask for the newer version; the update banner stayed hidden.
Now it shows the request ("You asked for version 1.47.0 on …") and, below it, that a newer
version has come out with **Ask for 1.47.3 instead** (Withdraw request stays). The banner shows
again and says to change the request. Changing it is audited as `hub_update_requested` with
`changedFrom`.
- With Cross Events Hub 1.5.0 the Hub moves the open request on to the new version and emails the
provider once; with an older Hub the site withdraws the request and makes it again.
## [1.47.3] - 2026-10-28
### Added
- **Season passes** — one price for a ticket at each of several events in a series. On the Event
series page supervisors make a pass (name, price, optional sale end) and tick which events and
which ticket (and variant) at each it includes; the form shows what they'd cost separately. Youth
(parental consent) events, staff-only tickets and per-session tickets can't be on a pass. Events
and price are fixed once someone has bought it (switch it off instead); campus-scoped supervisors
can only use their campuses' events.
- **Buying** — on the series page signed-in people choose how many and buy: they're registered at
every event on the pass at once and pay once (a free pass gives the tickets straight away). If
online payment isn't possible the places stay booked and "Your passes" offers **Pay now**. A pass
stops selling when its first event starts.
- **Money per event** — the price is split over the events in proportion to their normal ticket
prices (whole cents). Each registration's line is priced at its event's share, and the payment is
recorded as one payment per event (all linked to the purchase), so each event's cashup and reports
count only its part. Pass registrations aren't repriced by early-bird changes, take no coupon
codes, and their tickets can't be edited.
- `/api/season-passes` (list, create, change, delete; `:id/buy`; `purchases/:id/checkout`; `mine`),
`passes` on `GET /api/series/by-slug/:slug`. Making, changing and buying passes is blocked in
licence read-only mode. Purchases are in "Download my data". Audit action `season_pass_changed`
(migration `20261028150000_season_pass`).
## [1.47.2] - 2026-10-28
### Added
- **Series: year on year** report (Reports → Other) — pick a series and see each of its events side
by side, oldest first: registrations (not cancelled), tickets, income (as on the dashboard: refunds
counted, donations applied to orders left out) and people checked in (a multi-session event shows
the average per session), with the attendance rate and the change from the event before. Bar charts
of registrations and income; exports to PDF, Excel, email and WhatsApp like the other reports.
Campus-scoped staff see only their campuses' events. **Compare** on each series on the Event series
page opens it (`?report=seriesComparison&series=<id>`).
- `GET /api/series/:id/compare`.
## [1.47.1] - 2026-10-28
### Added
- **Event series** — group separate events (Camp 2025, Camp 2026; the monthly men's breakfast) on
one page. Supervisors make series on the new **Event series** page (name, web address,
description, picture) and put an event in one with the **Series** box in the event editor; a
copied event keeps its series. A series can have events at several campuses.
- **Series page** `/series/<slug>`: what's coming up (event cards) and past events. Each event in a
series shows "Part of …" and **Next in this series** on its page.
- **Follow a series** — signed-in people follow on the series page. When a new event in the series
goes live (and hasn't started) followers get one email, sent as News & announcements: only to
people opted in to news, with the unsubscribe link and one-click headers. New `series` job every
15 minutes (`SERIES_JOBS_ENABLED`); an event moved into another series is announced to that
series' followers.
- Followed series are in "Download my data" and removed when an account is anonymised.
- API: `/api/series` (list, create, change; delete for admins; `by-slug`; follow/unfollow),
`seriesId` on event create/update, `?series=<slug>` on `GET /api/events`, `series` and
`nextInSeries` on `GET /api/events/:id`. Creating and changing series is blocked in licence
read-only mode. Audit actions `series_created`, `series_updated`, `series_deleted` (migration
`20261028140000_event_series`).
## [1.47.0] - 2026-10-28
### Added
+39 -2
View File
@@ -56,7 +56,7 @@ curl -fsSLO https://c-git.crosscode.co.za/joshua/hope-events/raw/branch/main/scr
sudo bash install.sh --domain events.yourchurch.org --no-ssl --version v1.41.2
```
It installs Node 20, PostgreSQL, PM2 and nginx, creates the database, writes the config, builds
It installs Node 24 LTS, PostgreSQL, PM2 and nginx, creates the database, writes the config, builds
and starts the app (in `/opt/hope-events`, run by the `hopeevents` user). Use `--name`, `--dir`,
`--web-port` and `--api-port` if other apps already use those names or ports; adjust the ports
below to match.
@@ -305,11 +305,48 @@ cd /opt/hope-events
sudo -u hopeevents -H bash scripts/deploy.sh v1.41.2 # or no tag for the latest main
```
It backs up the database, checks out the release, installs, migrates, builds and reloads PM2,
It backs up the database, checks out the release, installs, migrates, builds (the backend too,
since 1.51.0) and reloads PM2,
and goes back to the previous version if anything fails first. `backend/.env`,
`frontend/.env.production`, `ecosystem.config.js` and the nginx/frpc configs are not in git, so
updates keep your changes from this guide.
Since 1.52.2 it first checks for files in the install folder that were changed or added by hand
(e.g. by a setup wizard run on the server) and stops, listing them, before it backs anything up
or checks anything out: they would be built into the release, or block the checkout. Move added
files out of the folder and put changed ones back with `git checkout -- <file>`, or deploy anyway
with `ALLOW_LOCAL_CHANGES=1`. To check without deploying:
```bash
sudo -u hopeevents -H bash scripts/deploy.sh --check
```
An install still on an older release can run the new check without updating anything — fetch the
script on its own and run it from the install folder:
```bash
git fetch -q origin && git show origin/main:scripts/deploy.sh > /tmp/deploy.sh
bash /tmp/deploy.sh --check
```
### Upgrading Node
Node 20 stopped getting security fixes in April 2026; Cross Events runs on Node 24 LTS (supported
until April 2028). `deploy.sh` warns when a server is still on an older Node. To upgrade:
```bash
node -v # what you have now
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo bash -
sudo apt-get install -y nodejs # replaces the old Node
sudo npm install -g pm2 # PM2 for the new Node
cd /opt/hope-events
sudo -u hopeevents -H pm2 update # restart PM2's daemon on the new Node
sudo -u hopeevents -H bash scripts/deploy.sh v1.51.4 # reinstall packages and rebuild for Node 24
```
Use the version you're on (or a newer one) in the last line — the deploy reinstalls every package
and rebuilds, which a Node upgrade needs.
## Troubleshooting
| Symptom | Likely cause |
+139 -22
View File
@@ -29,15 +29,17 @@ WhatsApp · PM2 for process management · optional Sentry for error monitoring.
13. [Cashup & event costs](#cashup--event-costs)
14. [Reports](#reports)
15. [Site settings & setup wizard](#site-settings--setup-wizard)
16. [Multi-campus](#multi-campus)
17. [Kids check-in](#kids-check-in)
18. [Volunteer rosters](#volunteer-rosters)
19. [Accounts & security](#accounts--security)
20. [Privacy (POPIA)](#privacy-popia)
21. [Help system](#help-system)
22. [Background jobs](#background-jobs)
23. [Operations](#operations)
24. [Page map](#page-map)
16. [Event series](#event-series)
17. [Multi-campus](#multi-campus)
18. [Kids check-in](#kids-check-in)
19. [Volunteer rosters](#volunteer-rosters)
20. [Accounts & security](#accounts--security)
21. [Privacy (POPIA)](#privacy-popia)
22. [Accessibility](#accessibility)
23. [Help system](#help-system)
24. [Background jobs](#background-jobs)
25. [Operations](#operations)
26. [Page map](#page-map)
---
@@ -325,6 +327,12 @@ Feedback → Attendance**.
Date, plus Statement and Heading blocks; required/optional, help text,
reordering. The whole form can be marked required.
- **Drafts** — partially filled forms are auto-saved.
- **Required forms and tickets** — a required form (with at least one question)
holds a registration's tickets back until a form is in for each person (a
picked-session booking counts each person once). At the door and in Manual
registration the form pops up to fill in with the attendee; **Fill in later**
(any staff) issues the tickets anyway once it's paid and sends the payer a link
to the form. The kiosk asks the visitor for exactly the forms still missing.
- **Responses** — view, filter (event, user, past/inactive) and print one
response per A4 page. Supervisors can edit responses.
@@ -413,9 +421,24 @@ logout controls. Disconnected sessions are recovered automatically.
## Supervisor tools
- **Dashboard** — KPIs (active events, revenue, donations, registrations,
tickets sold, each vs. the previous month), revenue trend chart, top events,
and recent scans. Tiles link to the matching report.
- **Choosing an event** (staff and supervisor pages: scanner, event tickets, at the door, payments,
manual registration, email and WhatsApp, forms, attendance, consent, waitlist, allocations,
bundles, offline scans, kids and volunteer set-up, manage registrations, kiosk): a searchable
picker instead of a long dropdown. It opens on upcoming events, soonest first; **Show past
events** lists past ones (latest first), and typing searches every event by name, campus or date.
Arrow keys, Enter and Escape work. `components/shared/EventPicker.tsx`.
- **Dashboard** — **Today** (each event or session on today: checked in so far
against places, children in now, volunteers here; money in today and this
week) and **Waiting for you** (unpaid registrations for the next two weeks,
parental consent outstanding, waitlists, cover requests, roster gaps; for
admins also cashups due, payments to reconcile, privacy requests, failed
webhooks — each links to where it's dealt with); then KPIs (active events,
revenue, donations, registrations, tickets sold, each vs. the previous month),
revenue trend chart, top events, and recent scans. Staff see Today too.
- **Command palette (Ctrl K / ⌘K)** — anywhere in the dashboard: pages, settings
and reports for the role, actions ("New event", "Scan tickets"…), and search:
events; payments by amount ("R450") for supervisors; people and registrations
for admins. Follows campus scoping.
- **Manage events** — the 9-step event wizard, ticket options, variants,
sections, form and attachments.
- **At the door** — search by name/phone/ticket ID, walk-in registration,
@@ -466,12 +489,28 @@ logout controls. Disconnected sessions are recovered automatically.
[Privacy](#privacy-popia)).
- **Registrations** — all registrations, with filters (event, status, text),
status counts, revenue and outstanding totals, and per-registration options,
form answers and payments (with who recorded each one).
form answers and payments (with who recorded each one). **Bulk actions** on
ticked registrations (or all shown): message (email/WhatsApp), mark paid
(records the balance by method), move to another ticket (same tickets and QR
codes, repriced), put in a room or group, export to Excel, cancel — each
through the same rules as one at a time, with a result per registration.
- **Saved filters** — on Manage registrations, Users, Events and Reports: name the
current filters, apply one with a click, star one as the default for the page
(not applied over a deep link). Per person, on the server.
- **Cashup** — see below.
- **Corrections** (`/dashboard/admin/corrections`, admins only) — for any event,
past and closed ones too: registrations missing tickets, with forms to fill in,
or owing money. **Issue all missing tickets** at once (forms left for later),
fill in a form, **issue tickets — pay later** (balance still owed), **make it
free** (the balance written off as a complimentary discount, so income stays
what was paid; not on closed events), and **check people in (or undo) after the
event** for any session. Optionally send the tickets. Every correction is in
the audit log with the reason given.
- **Audit log** (`/dashboard/admin/audit-log`) — who did what and when for
refunds, donation assign/unassign, manual registrations, staff
cancellations, event create/update/delete, settings changes, and coupon codes
(created, edited, turned off, applied or removed by staff).
(created, edited, turned off, applied or removed by staff), and every later feature's admin
actions. Search by person, target or IP; filter by action, role and dates; 50 a page.
- **Site settings** — see below.
---
@@ -497,7 +536,7 @@ logout controls. Disconnected sessions are recovered automatically.
## Reports
A full-width workspace with shared filters (events, date range with presets,
A full-width workspace with shared filters (events, searchable, with "Select these N" for the matches; date range with presets,
include past/inactive/closed) and a searchable grid of reports, each opening in
a viewer with its own filters, stat tiles and bar charts.
@@ -508,6 +547,7 @@ a viewer with its own filters, stat tiles and bar charts.
| Ticketing | Ticket usage summary |
| Donations | Donations breakdown (used / unused per event, plus the sponsorship pool) |
| Cashup | Cashup reconciliation · Cashup audit trail |
| Other | Series: year on year (a series' events side by side — registrations, tickets, income, checked in, attendance rate, change from the one before) |
**Export:** Print, branded PDF download, styled Excel (`.xlsx`) workbook,
Email (to yourself), or WhatsApp (the PDF to your own number). Dashboard tiles
@@ -527,7 +567,7 @@ applied without a restart. Secrets are encrypted with AES-256-GCM.
|-----|----------|
| Organisation | Name, tagline, contact email/phone, address (with map link) |
| Campuses | Off by default. Turn on, then add, order, archive or delete campuses (name, web address, address, contact, colour, logo, default event location, daily-summary recipients) |
| Branding | Primary/secondary/accent colours, logo, favicon, colours suggested from the logo, live website/email preview on desktop and mobile |
| Branding | Primary/secondary/accent colours (with a readability warning and a darker shade offered when the primary is too light), logo, favicon, colours suggested from the logo, live website/email preview on desktop and mobile |
| Notifications | Registrations inbox and admin notification emails |
| Email | SMTP host/port/TLS/user/password/from, with a "Test connection" button |
| Legal | Operator, Information Officer, website, effective date for the Terms/Privacy pages |
@@ -539,6 +579,33 @@ See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
---
## Event series
Separate events grouped on one page — every year's camp, the monthly men's breakfast (each event
keeps its own registrations; for one event on several dates see multi-day events).
- **Event series** page (supervisors): name, web address (`/series/<slug>`), description, picture;
each series lists its events (campus-scoped supervisors see only their campuses' events in it)
and its number of followers. Admins can delete a series (its events stay).
- **Series box** in the event editor's Basic details. A copied event keeps its series. A series can
have events at several campuses.
- **Public series page**: upcoming events as cards, then past events. Event pages in a series show
"Part of …" and **Next in this series** (the next public event in it).
- **Follow a series**: signed-in people follow on the series page; when a new event in it goes live
(before it starts) they get one email, sent as News & announcements (opted-in only, with an
unsubscribe link). The `series` job runs every 15 minutes.
- **Year on year**: Reports → Series: year on year (or **Compare** on the Event series page)
compares the series' events — registrations, tickets, income, checked in (average per session on
multi-session events), attendance rate — each against the one before.
- **Season passes**: on the Event series page, a pass gives a ticket (and variant) at each event
ticked, at one price (optional sale end; stops when its first event starts; no youth events,
staff-only or per-session tickets). Buyers choose how many on the series page and are registered at
every event at once with one payment (or tickets at once when free; "Pay now" later if online
payment isn't possible). The price is split over the events in proportion to their normal prices;
each event gets its own registration line and payment for its share, so cashups stay per event.
Pass registrations aren't repriced, take no codes and can't be edited. Events and price lock once
sold.
## Multi-campus
For a church with several campuses in one install (Site settings → Campuses, off by
@@ -700,8 +767,12 @@ and published, numbered versions (earlier ones stay readable with `?version=N`).
Placeholders (`{org}`, `{ioName}`, `{ioEmail}`, `{website}`, `{orgEmail}`,
`{medicalRetentionDays}`, `{operators}`) are filled in from Site settings and the
operator register. A version marked as a significant change is accepted once by everyone
after their next login; sign-up records the version accepted. The contact page shows the
Information Officer.
after their next login. People agree to the notice at sign-up and when they activate an account
made for them; anyone who never has is asked once after login. When the built-in text changes
in an update, a site with its own published version sees what changed in the editor, with each
changed section marked (**Use the new text**, **Use all the new text**, **Add** missing sections,
**Done — hide this**). The "Change my cookie choice" button shows above the cookie section. The
contact page shows the Information Officer.
**Registers and logs** (same admin page):
@@ -724,7 +795,7 @@ Information Officer.
and the people affected were told, and drafts of both notifications.
- **Readiness** — a scored checklist of what's set up, each item linking to its fix.
**For admins** — **Data requests** (`/dashboard/admin/data-requests`): correction and
**For admins** — **Data requests** (`/dashboard/admin/data-requests`, searchable, closed ones paged): correction and
deletion requests with a 30-day due date, emailed to the Information Officer and
admins, reminded daily when due within a week. Completing a deletion anonymises the
person: name, contact details, the people they registered, form answers, consent
@@ -735,6 +806,18 @@ log.
---
## Accessibility
Built to WCAG 2.2 AA on the public pages, sign-in, registration, payment, My Events and the
self-service kiosk (1.50.0): "Skip to main content" link, visible keyboard focus, labelled fields,
named buttons (ticket quantities say "Add one …"), errors announced as they appear, popups that
trap focus and close with Escape, readable contrast with underlined in-text links, and "reduce
motion" respected. Branding warns when the primary colour is too light for white text and
suggests a darker shade. axe-core checks and keyboard tests in the e2e suite
(`e2e/accessibility.spec.ts`) keep it that way.
---
## Help system
A floating **"Need help?"** button on every page opens a page-specific guide
@@ -743,6 +826,27 @@ falling back to a general guide.
---
## Outbound webhooks
Site settings → **Webhooks** (admins): send what happens to other systems as it happens.
- **Endpoints**: name, https address, and the event types to send — registration created / paid /
cancelled, payment received / refunded, ticket checked in, account created, event published.
Switch off, edit, delete, **Send test**, **New secret**.
- **Messages**: JSON `{ id, type, createdAt, data }` (registration with items and totals, payment,
check-in with session, person, event); headers `X-CrossEvents-Event`, `X-CrossEvents-Delivery` and
`X-CrossEvents-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<body>">` with the endpoint's secret.
- **Delivery log** per endpoint (30 days): status, tries, response code or error, the full message,
**Resend**; filter by status and **Load older**. Retries after 1 min, 5 min, 30 min, 2 h, 12 h, then failed.
- Every path that registers, pays, cancels, refunds or checks in is covered (Prisma client
extension); rolled-back changes send nothing. Kids check-in and volunteer tickets are never sent.
- https and public addresses only (DNS checked at each send, redirects not followed). Each endpoint
is added to the operator register; deliveries are deleted after 30 days and with an account.
- **How webhooks work** (1.50.2), a guide on the tab (open until the first webhook is added): the
envelope and headers, retries and duplicates, checking the signature (Node.js and PHP examples),
Zapier/Make set-up, and a copyable example message for every event type. The examples come from
`EVENT_TYPES[].example` in `utils/webhooks.js`; a test keeps them the same shape as what is sent.
## Background jobs
Timed jobs run on a job queue in Postgres (pg-boss, `backend/src/jobs/index.js`): each
@@ -762,6 +866,8 @@ can't start, the same jobs run on in-process timers.
| `feedback` — post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false`) |
| `sessions` — session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false`) |
| `volunteers` — volunteer reminders the day before a shift | Every 15 minutes (`VOLUNTEER_JOBS_ENABLED=false`) |
| `series` — email a series' followers when a new event in it goes live | Every 15 minutes (`SERIES_JOBS_ENABLED=false`) |
| `webhooks` — send webhook deliveries that are due, retry failures, delete deliveries after 30 days | Every minute (`WEBHOOKS_JOBS_ENABLED=false`) |
| `consent` — parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false`) |
| `privacy` — remind admins of privacy requests due within a week or overdue | 08:00 daily (`PRIVACY_JOBS_ENABLED=false`) |
| `retention` — delete/anonymise personal information past its retention period (Admin → Privacy → Retention) | 03:30 daily (`RETENTION_JOBS_ENABLED=false`) |
@@ -784,7 +890,8 @@ can't start, the same jobs run on in-process timers.
created or edited; no new registrations or payments; tickets still scan, data can be exported).
The site checks in 30 seconds after starting and after a licence key is connected. Admins see when an update is available, with
**what's new** in each version, and press **Request update**, which tells the provider through the Hub (the site never updates
itself). A request can be **withdrawn**. **Not now** hides the update banner until the next version; urgent updates (security or
itself). A request can be **withdrawn**; when a newer version comes out after asking, the page shows
the version asked for and **Ask for … instead** moves the request on to the newer one. **Not now** hides the update banner until the next version; urgent updates (security or
serious bug fixes) can't be put off and show a red banner.
- **Notices from the provider** — maintenance and other messages the provider posts in the Hub show as a banner on the dashboard
for site admins and supervisors, in their time window; each can be closed (per browser) and stays listed on Site settings →
@@ -796,7 +903,16 @@ 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`).
- 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
OpenAPI document. v1 endpoints with a v2 replacement send `Deprecation` and a `successor-version`
link (1.51.3).
- **TypeScript backend** (1.51.0): `.ts` files are checked strictly, `.js` compiles as it is;
deploys build `dist/`, which `src/index.js` runs.
- **Command palette** (Ctrl+K): every page, settings tab, report and quick action for your role.
- **Tests** — Jest unit tests in `backend/tests` (`npm test`), and Playwright
end-to-end tests in `e2e/` (`npm run test:e2e`) that start their own backend and
frontend against a separate, freshly seeded database: public pages, free and paid
@@ -816,6 +932,7 @@ can't start, the same jobs run on in-process timers.
| `/` | Public | Home |
| `/events`, `/events/[id]` | Public | Event list and detail |
| `/campus/[slug]` | Public | A campus's page and its events (while campuses are on) |
| `/series/[slug]` | Public | A series' page: upcoming and past events, Follow |
| `/[alias]` | Public | Event short link |
| `/register/[eventId]` | Public / User | Registration |
| `/registration/success` | Public / User | Confirmation, form and pay |
@@ -835,4 +952,4 @@ can't start, the same jobs run on in-process timers.
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/privacy`, `/kids`, `/serving`, `/reset-password`) | User | My Events |
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`, `/my-groups`, `/kids`, `/kids/rooms`, `/volunteers`) | Staff | Staff tools |
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/attendance`, `/consent`, `/offline-scans`, `/allocations`, `/kids`, `/volunteers`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/data-requests`, `/privacy`, `/settings`) | Admin | Admin tools |
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/corrections`, `/audit-log`, `/data-requests`, `/privacy`, `/settings`) | Admin | Admin tools |
+408
View File
@@ -0,0 +1,408 @@
# Moving to Cross Events 2.0
Cross Events 2.0 removes version 1 of the API (`/api/…`). Everything the app does has been on
version 2 (`/api/v2/…`) since 1.51.16, so **a church that only uses the app has nothing to do** —
the update looks the same as any other. This guide is for anyone with their own integration (a
script, a website plug-in, a spreadsheet import) that calls the API directly, and for whoever runs
the server.
## What still works without changes
- **Payment and WhatsApp webhooks.** Yoco, PayFast and WAWP keep posting to the addresses they were
registered with: `/api/webhooks/yoco`, `/api/webhooks/payfast`, `/api/webhooks/whatsapp` and the
oldest WAWP address, `/api/whatsapp/webhook`. New registrations use `/api/v2/webhooks/…`.
- **Links in messages already sent.** "Add to calendar" links (`/api/events/{id}/ics`) and one-click
unsubscribe links (`/api/privacy/unsubscribe/{token}/one-click`) answer `308` with their v2
address; a mail app's one-click `POST` to the old address is still handled.
- **Outbound webhooks** (Site settings → Webhooks) send the same events and payloads as before.
- **Pages people open** — event pages, consent forms, ticket transfers, waitlist offers — are app
pages, not API addresses, so they were never affected.
Any other `/api/…` address now answers **`410 Gone`**:
```json
{ "code": "API_V1_REMOVED", "message": "API v1 was removed in Cross Events 2.0. Use /api/v2 — it is described at /docs/v2." }
```
`/docs` (the v1 reference) redirects to `/docs/v2`.
## How v2 differs
The full reference is at `/docs/v2` on your site (sign in as an admin), generated from the code so
it is always current. The OpenAPI 3.1 document is `GET /api/v2/openapi.json`.
| | v1 | v2 |
|---|---|---|
| Answers | whatever each endpoint returned | always `{ "data": … }` |
| Lists | plain arrays, some paged | `{ "data": [ … ], "meta": { "page", "limit", "total", "pages" } }`, `?page=` and `?limit=` (most lists up to 100) |
| Errors | `{ "message", "code"? }`, often `400` | `{ "error": { "code", "message", "reason"?, "fields"? } }` — `code` is one of a fixed set (`VALIDATION_FAILED`, `UNAUTHENTICATED`, `FORBIDDEN`, `NOT_FOUND`, `CONFLICT`, `READ_ONLY`, `RATE_LIMITED`, `UNAVAILABLE`, `INTERNAL`); v1's `code` is now `reason` |
| Input | anything | checked against the documented schema; mistakes are `VALIDATION_FAILED` with `fields` |
| Changing a record | mostly `PUT` | `PATCH` for partial changes (events, ticket options, variants, series, users, coupons…) |
| Dates | mixed | ISO 8601, UTC |
Things that behave differently, not just live at a new address:
- **Signing in:** `POST /api/v2/auth/login` takes `{ identifier, password }` (an email or phone
number). If two-step sign-in is on, the answer is `status: "code_sent"` with a `challengeId`;
finish with `POST /api/v2/auth/login/code`.
- **Signing up:** `POST /api/v2/auth/signup` needs `acceptTerms: true`; the phone number is
`phone`. The answer's `data` holds `token` and `user`.
- **Checking a ticket in:** `POST /api/v2/checkin/{qrCode}` with `{ people? }` (was `qty`). Every
normal result is a `200` with an `outcome` — `checked_in`, `already_used`, `consent_required`,
`wrong_event`, … — where v1 used `403`s. Look a ticket up first with `GET /api/v2/checkin/{qrCode}`.
- **Paying online:** `POST /api/v2/registrations/{id}/checkout` (the registration is in the path,
not the body).
- **Cancelling a registration:** `POST /api/v2/registrations/{id}/cancel` (was `DELETE`).
- **Staff lists of registrations** are paged (up to 200 a page).
- **A suspended or unlicensed site** refuses new events, registrations and payments with
`READ_ONLY` / reason `LICENCE_READ_ONLY`.
## For whoever runs the server
1. Deploy as usual: `bash scripts/deploy.sh v2.0.0` (it backs up, migrates, builds and rolls back
on failure). Run `bash scripts/deploy.sh --check` first if anyone may have changed files by hand.
2. Two migrations run: `20261030120000_ai_helpers` (the AI call log) and anything newer listed in the
CHANGELOG.
3. **AI helpers** need the site connected to the Hub (Site settings → Licence) and a monthly AI cap
above R0 on the licence. The Hub must run 1.6.0 or later. With no cap the AI buttons don't show
and nothing else changes.
4. If something outside Cross Events still calls `/api/…`, it will get `410` after the update — look
for those in the nginx access log before updating (`grep -v '/api/v2/' access.log | grep ' /api/'`,
leaving out the webhook and link addresses above).
5. Passwords are re-hashed with a stronger method the next time each person signs in; nothing to do.
## Every v1 address and its v2 replacement
`{id}` is the record's id. `{a|b}` means either. Methods that changed are shown in the v2 column.
| v1 | v2 |
|---|---|
| `GET /api/events` | `GET /api/v2/events` |
| `GET /api/events/{id}` | `GET /api/v2/events/{id}/full` |
| `GET /api/events/{id}/ics` | `GET /api/v2/events/{id}/calendar.ics` |
| `GET /api/events/by-alias/{id}` | `GET /api/v2/events/by-alias/{id}` |
| `POST /api/events/{id}/options` | `POST /api/v2/events/{id}/ticket-options` |
| `PUT /api/events/options/{id}` | `PATCH /api/v2/ticket-options/{id}` |
| `DELETE /api/events/options/{id}` | `DELETE /api/v2/ticket-options/{id}` |
| `POST /api/events/options/{id}/variants` | `POST /api/v2/ticket-options/{id}/variants` |
| `PUT /api/events/variants/{id}` | `PATCH /api/v2/variants/{id}` |
| `DELETE /api/events/variants/{id}` | `DELETE /api/v2/variants/{id}` |
| `GET /api/events/attachments/status` | `GET /api/v2/attachments/status` |
| `POST /api/events/attachments/sync` | `POST /api/v2/attachments/sync` |
| `GET /api/events/{id}/attachments` | `GET /api/v2/events/{id}/attachments` |
| `POST /api/events/{id}/attachments` | `POST /api/v2/events/{id}/attachments` |
| `DELETE /api/events/{id}/attachments/{id2}` | `DELETE /api/v2/events/{id}/attachments/{id2}` |
| `GET /api/sections` | `GET /api/v2/sections` |
| `POST /api/sections` | `POST /api/v2/sections` |
| `PUT /api/sections/{id}` | `PATCH /api/v2/sections/{id}` |
| `DELETE /api/sections/{id}` | `DELETE /api/v2/sections/{id}` |
| `GET /api/events/{id}/costs` | `GET /api/v2/events/{id}/costs` |
| `POST /api/events/{id}/costs` | `POST /api/v2/events/{id}/costs` |
| `PUT /api/costs/{id}` | `PATCH /api/v2/costs/{id}` |
| `DELETE /api/costs/{id}` | `DELETE /api/v2/costs/{id}` |
| `POST /api/uploads/{event-image|logo|favicon}` | `POST /api/v2/uploads/{event-image|logo|favicon}` |
| `GET /api/events/all` | `GET /api/v2/events/manage` |
| `POST /api/events` | `POST /api/v2/events` |
| `PUT /api/events/{id}` | `PATCH /api/v2/events/{id}` |
| `DELETE /api/events/{id}` | `DELETE /api/v2/events/{id}` |
| `POST /api/events/{id}/copy-from` | `POST /api/v2/events/{id}/copy-from` |
| `GET /api/events/{id}/notify-recipients` | `GET /api/v2/events/{id}/notify-recipients` |
| `PUT /api/events/{id}/notify-recipients` | `PUT /api/v2/events/{id}/notify-recipients` |
| `POST /api/webhooks/{yoco|payfast|whatsapp}` | `POST /api/v2/webhooks/{yoco|payfast|whatsapp}` |
| `POST /api/whatsapp/webhook` | `POST /api/v2/webhooks/whatsapp` |
| `POST /api/privacy/unsubscribe/{id}/one-click` | `POST /api/v2/unsubscribe/{id}/one-click` |
| `GET /api/privacy/me` | `GET /api/v2/me/privacy` |
| `PUT /api/privacy/me/news` | `PUT /api/v2/me/privacy/news` |
| `GET /api/privacy/me/export` | `GET /api/v2/me/privacy/export` |
| `POST /api/privacy/me/requests` | `POST /api/v2/me/privacy/requests` |
| `POST /api/privacy/me/notice/accept` | `POST /api/v2/me/privacy/notice/accept` |
| `GET /api/privacy/notice` | `GET /api/v2/privacy/notice` |
| `GET /api/privacy/unsubscribe/{id}` | `GET /api/v2/unsubscribe/{id}` |
| `POST /api/privacy/unsubscribe/{id}` | `POST /api/v2/unsubscribe/{id}` |
| `GET /api/privacy/requests` | `GET /api/v2/privacy/requests` |
| `POST /api/privacy/requests/{id}/{complete|reject}` | `POST /api/v2/privacy/requests/{id}/{complete|reject}` |
| `GET /api/privacy/notice/draft` | `GET /api/v2/privacy/notice/draft` |
| `PUT /api/privacy/notice/draft` | `PUT /api/v2/privacy/notice/draft` |
| `DELETE /api/privacy/notice/draft` | `DELETE /api/v2/privacy/notice/draft` |
| `POST /api/privacy/notice/{publish|builtin-reviewed}` | `POST /api/v2/privacy/notice/{publish|builtin-reviewed}` |
| `GET /api/privacy/operators` | `GET /api/v2/privacy/operators` |
| `POST /api/privacy/operators` | `POST /api/v2/privacy/operators` |
| `PUT /api/privacy/operators/{id}` | `PUT /api/v2/privacy/operators/{id}` |
| `DELETE /api/privacy/operators/{id}` | `DELETE /api/v2/privacy/operators/{id}` |
| `GET /api/privacy/{consent-log|access-log|readiness|retention|breaches}` | `GET /api/v2/privacy/{consent-log|access-log|readiness|retention|breaches}` |
| `PUT /api/privacy/retention` | `PUT /api/v2/privacy/retention` |
| `POST /api/privacy/retention/run` | `POST /api/v2/privacy/retention/run` |
| `POST /api/privacy/breaches` | `POST /api/v2/privacy/breaches` |
| `PUT /api/privacy/breaches/{id}` | `PUT /api/v2/privacy/breaches/{id}` |
| `GET /api/admin/{audit-log|jobs|impersonation-sessions}` | `GET /api/v2/admin/{audit-log|jobs|impersonation-sessions}` |
| `POST /api/admin/jobs/{id}/run` | `POST /api/v2/admin/jobs/{id}/run` |
| `POST /api/admin/impersonate/{end|log}` | `POST /api/v2/me/impersonation/{end|log}` |
| `GET /api/admin/impersonate/current` | `GET /api/v2/me/impersonation` |
| `POST /api/admin/impersonate/{id}` | `POST /api/v2/admin/impersonate/{id}` |
| `GET /api/settings/needs-setup` | `GET /api/v2/setup/needed` |
| `GET /api/settings/all` | `GET /api/v2/settings/all` |
| `GET /api/settings` | `GET /api/v2/settings` |
| `PUT /api/settings` | `PUT /api/v2/settings` |
| `POST /api/settings/test-smtp` | `POST /api/v2/settings/test-smtp` |
| `POST /api/setup/register` | `POST /api/v2/setup/register` |
| `POST /api/setup` | `POST /api/v2/setup` |
| `GET /api/hub/{status|notices}` | `GET /api/v2/hub/{status|notices}` |
| `POST /api/hub/{connect|heartbeat|update-request|update-snooze}` | `POST /api/v2/hub/{connect|heartbeat|update-request|update-snooze}` |
| `POST /api/hub/update-request/cancel` | `POST /api/v2/hub/update-request/cancel` |
| `GET /api/outbound-webhooks` | `GET /api/v2/outbound-webhooks` |
| `POST /api/outbound-webhooks` | `POST /api/v2/outbound-webhooks` |
| `POST /api/outbound-webhooks/deliveries/{id}/resend` | `POST /api/v2/outbound-webhook-deliveries/{id}/resend` |
| `PUT /api/outbound-webhooks/{id}` | `PATCH /api/v2/outbound-webhooks/{id}` |
| `DELETE /api/outbound-webhooks/{id}` | `DELETE /api/v2/outbound-webhooks/{id}` |
| `POST /api/outbound-webhooks/{id}/{rotate-secret|test}` | `POST /api/v2/outbound-webhooks/{id}/{rotate-secret|test}` |
| `GET /api/outbound-webhooks/{id}/deliveries` | `GET /api/v2/outbound-webhooks/{id}/deliveries` |
| `GET /api/backups` | `GET /api/v2/backups` |
| `POST /api/backups/run` | `POST /api/v2/backups/run` |
| `GET /api/backups/{id}/download` | `GET /api/v2/backups/{id}/download` |
| `GET /api/{yoco|payfast}/config` | `GET /api/v2/payments/{yoco|payfast}/config` |
| `PUT /api/{yoco|payfast}/config` | `PUT /api/v2/payments/{yoco|payfast}/config` |
| `GET /api/yoco/webhooks` | `GET /api/v2/payments/yoco/webhooks` |
| `POST /api/yoco/webhooks` | `POST /api/v2/payments/yoco/webhooks` |
| `DELETE /api/yoco/webhooks/{id}` | `DELETE /api/v2/payments/yoco/webhooks/{id}` |
| `POST /api/payfast/test` | `POST /api/v2/payments/payfast/test` |
| `POST /api/users/login/2fa/resend` | `POST /api/v2/auth/login/code/resend` |
| `POST /api/users/forgot` | `POST /api/v2/auth/password/forgot` |
| `POST /api/users/reset` | `POST /api/v2/auth/password/reset` |
| `POST /api/users/activate` | `POST /api/v2/auth/activate` |
| `POST /api/users/revoke-sessions` | `POST /api/v2/me/sessions/revoke` |
| `POST /api/users/close-account` | `POST /api/v2/me/close` |
| `GET /api/users/activity` | `GET /api/v2/me/activity` |
| `POST /api/users/verify-password` | `POST /api/v2/me/verify-password` |
| `POST /api/users/2fa/{enable|confirm|disable}` | `POST /api/v2/me/two-factor/{enable|confirm|disable}` |
| `GET /api/users/check-exists` | `GET /api/v2/users/exists` |
| `POST /api/users/{id}/revoke-sessions` | `POST /api/v2/users/{id}/sessions/revoke` |
| `POST /api/users/{id}/anonymize` | `POST /api/v2/users/{id}/anonymize` |
| `GET /api/users` | `GET /api/v2/users` |
| `GET /api/users/{?!profile$}{id}` | `GET /api/v2/users/{?!profile$}` |
| `PUT /api/users/{?!profile$}{id}` | `PATCH /api/v2/users/{?!profile$}` |
| `DELETE /api/users/{id}` | `DELETE /api/v2/users/{id}` |
| `GET /api/events/admin/all` | `GET /api/v2/admin/events` |
| `POST /api/events/{id}/{email|whatsapp}-attendees` | `POST /api/v2/events/{id}/{email|whatsapp}-attendees` |
| `POST /api/events/{id}/{email|whatsapp}-attendees/schedule` | `POST /api/v2/events/{id}/{email|whatsapp}-attendees/schedule` |
| `POST /api/broadcasts/{preview|send|schedule}` | `POST /api/v2/broadcasts/email/{preview|send|schedule}` |
| `POST /api/whatsapp-broadcasts/{preview|send|schedule}` | `POST /api/v2/broadcasts/whatsapp/{preview|send|schedule}` |
| `GET /api/scheduled-emails` | `GET /api/v2/scheduled-messages` |
| `PATCH /api/scheduled-emails/{id}` | `PATCH /api/v2/scheduled-messages/{id}` |
| `DELETE /api/scheduled-emails/{id}` | `DELETE /api/v2/scheduled-messages/{id}` |
| `POST /api/automations/schedule` | `POST /api/v2/automations/schedule` |
| `GET /api/whatsapp/{config|status|qr}` | `GET /api/v2/whatsapp/{config|status|qr}` |
| `POST /api/whatsapp/config` | `GET/PUT /api/v2/whatsapp/config` |
| `POST /api/whatsapp/create-instance` | `POST /api/v2/whatsapp/instance` |
| `POST /api/whatsapp/delete-instance` | `POST /api/v2/whatsapp/instance` |
| `POST /api/whatsapp/{request-code|logout|start|restart}` | `POST /api/v2/whatsapp/{request-code|logout|start|restart}` |
| `GET /api/banner` | `GET /api/v2/banner` |
| `POST /api/banner` | `GET/PUT /api/v2/banner` |
| `GET /api/saved-filters` | `GET /api/v2/saved-filters` |
| `POST /api/saved-filters` | `POST /api/v2/saved-filters` |
| `PUT /api/saved-filters/{id}` | `PUT /api/v2/saved-filters/{id}` |
| `DELETE /api/saved-filters/{id}` | `DELETE /api/v2/saved-filters/{id}` |
| `GET /api/dashboard/{today|search}` | `GET /api/v2/dashboard/{today|search}` |
| `GET /api/stats/{staff|supervisor|admin|overview}` | `GET /api/v2/stats/{staff|supervisor|admin|overview}` |
| `POST /api/analytics/track` | `POST /api/v2/analytics/track` |
| `GET /api/analytics/funnel` | `GET /api/v2/analytics/funnel` |
| `POST /api/reports/{pdf|excel|email|whatsapp}` | `POST /api/v2/reports/{pdf|excel|email|whatsapp}` |
| `GET /api/kids/me` | `GET /api/v2/me/family` |
| `POST /api/kids/me` | `POST /api/v2/me/family` |
| `PUT /api/kids/me` | `PATCH /api/v2/me/family` |
| `DELETE /api/kids/me` | `DELETE /api/v2/me/family` |
| `POST /api/kids/me/{children|adults}` | `POST /api/v2/me/family/{children|adults}` |
| `PUT /api/kids/me/{children|adults}/{id}` | `PUT /api/v2/me/family/{children|adults}/{id}` |
| `DELETE /api/kids/me/{children|adults}/{id}` | `DELETE /api/v2/me/family/{children|adults}/{id}` |
| `GET /api/kids/setup-events` | `GET /api/v2/kids/setup-events` |
| `GET /api/kids/events` | `GET /api/v2/kids/events` |
| `GET /api/kids/events/{id}/{settings|board|families|offline}` | `GET /api/v2/events/{id}/kids/{settings|board|families|offline}` |
| `PUT /api/kids/events/{id}/settings` | `PUT /api/v2/events/{id}/kids/settings` |
| `POST /api/kids/events/{id}/{families|check-in|check-out|duty|sync}` | `POST /api/v2/events/{id}/kids/{families|check-in|check-out|duty|sync}` |
| `GET /api/kids/events/{id}/families/qr/{id2}` | `GET /api/v2/events/{id}/kids/families/qr/{id2}` |
| `GET /api/kids/events/{id}/families/{id2}` | `GET /api/v2/events/{id}/kids/families/{id2}` |
| `POST /api/kids/events/{id}/families/{id2}/{parents|children|adults}` | `POST /api/v2/events/{id}/kids/families/{id2}/{parents|children|adults}` |
| `PUT /api/kids/events/{id}/families/{id2}/children/{id3}` | `PUT /api/v2/events/{id}/kids/families/{id2}/children/{id3}` |
| `GET /api/kids/events/{id}/code/{id2}` | `GET /api/v2/events/{id}/kids/code/{id2}` |
| `POST /api/kids/check-ins/{id}/{override|page}` | `POST /api/v2/kids/check-ins/{id}/{override|page}` |
| `PUT /api/kids/check-ins/{id}/room` | `PUT /api/v2/kids/check-ins/{id}/room` |
| `POST /api/kids/alerts/{id}/resolve` | `POST /api/v2/kids/alerts/{id}/resolve` |
| `GET /api/volunteers/me` | `GET /api/v2/me/volunteering` |
| `GET /api/volunteers/events/{id}` | `GET /api/v2/events/{id}/volunteer-roles` |
| `POST /api/volunteers/roles/{id}/sign-up` | `POST /api/v2/volunteer-roles/{id}/sign-up` |
| `DELETE /api/volunteers/slots/{id}` | `DELETE /api/v2/volunteer-slots/{id}` |
| `POST /api/volunteers/slots/{id}/swap` | `POST /api/v2/volunteer-slots/{id}/swap` |
| `DELETE /api/volunteers/swaps/{id}` | `DELETE /api/v2/volunteer-swaps/{id}` |
| `POST /api/volunteers/swaps/{id}/accept` | `POST /api/v2/volunteer-swaps/{id}/accept` |
| `POST /api/volunteers/check-in` | `POST /api/v2/volunteering/check-in` |
| `GET /api/volunteers/manage/events` | `GET /api/v2/volunteering/events` |
| `GET /api/volunteers/manage/hours` | `GET /api/v2/volunteering/hours` |
| `GET /api/volunteers/manage/events/{id}` | `GET /api/v2/events/{id}/volunteers` |
| `GET /api/volunteers/manage/events/{id}/{qr|settings}` | `GET /api/v2/events/{id}/volunteers/{qr|settings}` |
| `PUT /api/volunteers/manage/events/{id}/settings` | `PUT /api/v2/events/{id}/volunteers/settings` |
| `POST /api/volunteers/manage/events/{id}/roles` | `POST /api/v2/events/{id}/volunteer-roles` |
| `PUT /api/volunteers/manage/roles/{id}` | `PATCH /api/v2/volunteer-roles/{id}` |
| `DELETE /api/volunteers/manage/roles/{id}` | `DELETE /api/v2/volunteer-roles/{id}` |
| `POST /api/volunteers/manage/roles/{id}/assign` | `POST /api/v2/volunteer-roles/{id}/assign` |
| `POST /api/volunteers/manage/slots/{id}/check-in` | `POST /api/v2/volunteer-slots/{id}/check-in` |
| `DELETE /api/volunteers/manage/slots/{id}` | `POST /api/v2/volunteer-slots/{id}/remove` |
| `GET /api/tickets/mytickets` | `GET /api/v2/me/tickets` |
| `POST /api/tickets/email` | `POST /api/v2/me/tickets/email` |
| `GET /api/tickets/scans/recent` | `GET /api/v2/tickets/scans/recent` |
| `GET /api/tickets/scans/stats` | `GET /api/v2/tickets/scans/stats` |
| `POST /api/tickets/send-to` | `POST /api/v2/tickets/send` |
| `POST /api/tickets/generate` | `POST /api/v2/tickets/generate` |
| `PUT /api/tickets/email-sent` | `POST /api/v2/tickets/email-sent` |
| `GET /api/tickets/qr/{id}` | `GET /api/v2/tickets/qr/{id}` |
| `GET /api/tickets/event/{id}` | `GET /api/v2/events/{id}/tickets` |
| `GET /api/tickets/transfer-offer/{id}` | `GET /api/v2/ticket-transfers/{id}` |
| `POST /api/tickets/transfer-offer/{id}/{accept|decline}` | `POST /api/v2/ticket-transfers/{id}/{accept|decline}` |
| `GET /api/tickets` | `GET /api/v2/tickets` |
| `GET /api/tickets/{id}/transfers` | `GET /api/v2/tickets/{id}/transfers` |
| `POST /api/tickets/{id}/transfer` | `POST /api/v2/tickets/{id}/transfer` |
| `DELETE /api/tickets/{id}/transfer` | `DELETE /api/v2/tickets/{id}/transfer` |
| `GET /api/tickets/{id}` | `GET /api/v2/tickets/{id}` |
| `GET /api/scan-locks` | `GET /api/v2/scan-locks` |
| `POST /api/scan-locks` | `POST /api/v2/scan-locks` |
| `GET /api/scan-locks/reports` | `GET /api/v2/scan-locks/reports` |
| `POST /api/scan-locks/reports/{id}/review` | `POST /api/v2/scan-locks/reports/{id}/review` |
| `GET /api/scan-locks/{id}/snapshot` | `GET /api/v2/scan-locks/{id}/snapshot` |
| `POST /api/scan-locks/{id}/{sync|force-release}` | `POST /api/v2/scan-locks/{id}/{sync|force-release}` |
| `DELETE /api/scan-locks/{id}` | `DELETE /api/v2/scan-locks/{id}` |
| `GET /api/attendance/certificates/verify/{id}` | `GET /api/v2/certificates/verify/{id}` |
| `GET /api/attendance/certificates/mine` | `GET /api/v2/me/certificates` |
| `POST /api/attendance/certificates/preview` | `POST /api/v2/certificates/preview` |
| `GET /api/attendance/certificates/{id}/pdf` | `GET /api/v2/certificates/{id}/pdf` |
| `POST /api/attendance/certificates/{id}/revoke` | `POST /api/v2/certificates/{id}/revoke` |
| `GET /api/attendance/{id}/export` | `GET /api/v2/events/{id}/attendance/export` |
| `POST /api/attendance/{id}/mark` | `POST /api/v2/events/{id}/attendance` |
| `POST /api/attendance/{id}/certificates` | `POST /api/v2/events/{id}/certificates` |
| `POST /api/attendance/{id}/certificates/send` | `POST /api/v2/events/{id}/certificates/send` |
| `GET /api/attendance/{id}/certificates.pdf` | `GET /api/v2/events/{id}/certificates.pdf` |
| `GET /api/attendance/{id}` | `GET /api/v2/events/{id}/attendance` |
| `GET /api/allocations/my-groups` | `GET /api/v2/me/allocation-groups` |
| `GET /api/allocations/event/{id}` | `GET /api/v2/events/{id}/allocations` |
| `POST /api/allocations/sets` | `POST /api/v2/allocation-sets` |
| `PUT /api/allocations/sets/{id}` | `PATCH /api/v2/allocation-sets/{id}` |
| `DELETE /api/allocations/sets/{id}` | `DELETE /api/v2/allocation-sets/{id}` |
| `GET /api/allocations/sets/{id}/lists` | `GET /api/v2/allocation-sets/{id}/lists` |
| `POST /api/allocations/sets/{id}/{groups|auto}` | `POST /api/v2/allocation-sets/{id}/{groups|auto}` |
| `PUT /api/allocations/sets/{id}/assignments` | `PUT /api/v2/allocation-sets/{id}/assignments` |
| `DELETE /api/allocations/sets/{id}/assignments` | `DELETE /api/v2/allocation-sets/{id}/assignments` |
| `PUT /api/allocations/groups/{id}` | `PATCH /api/v2/allocation-groups/{id}` |
| `DELETE /api/allocations/groups/{id}` | `DELETE /api/v2/allocation-groups/{id}` |
| `GET /api/payments/providers` | `GET /api/v2/payments/providers` |
| `GET /api/payments/providers/settings` | `GET /api/v2/payments/providers/settings` |
| `PUT /api/payments/providers/settings` | `PUT /api/v2/payments/providers/settings` |
| `GET /api/payments/pay/{id}` | `GET /api/v2/payment-links/{id}` |
| `POST /api/payments/pay/{id}` | `POST /api/v2/payment-links/{id}` |
| `GET /api/payments/checkouts/open` | `GET /api/v2/payments/open-checkouts` |
| `POST /api/payments/checkouts/check` | `POST /api/v2/payments/open-checkouts/check` |
| `POST /api/payments/[yoco-]checkout/send` | `POST /api/v2/registrations/{id}/payment-link` |
| `GET /api/payments/mypayments` | `GET /api/v2/me/payments` |
| `GET /api/payments/admin/stats` | `GET /api/v2/payments/stats` |
| `GET /api/payments/registration/{id}` | `GET /api/v2/registrations/{id}/payments` |
| `GET /api/payments/event/{id}` | `GET /api/v2/events/{id}/payments` |
| `PUT /api/payments/assign-donation` | `POST /api/v2/payments/donations/assign` |
| `POST /api/payments/unassign-donation` | `POST /api/v2/payments/donations/unassign` |
| `POST /api/payments/refund` | `POST /api/v2/payments/refunds` |
| `POST /api/payments/{id}/send-receipt` | `POST /api/v2/payments/{id}/receipt` |
| `GET /api/payments` | `GET /api/v2/payments` |
| `POST /api/payments` | `POST /api/v2/payments` |
| `GET /api/payments/{id}` | `GET /api/v2/payments/{id}` |
| `GET /api/cashups/audit` | `GET /api/v2/cashups/audit` |
| `GET /api/cashups/event/{id}` | `GET /api/v2/events/{id}/cashup` |
| `GET /api/cashups/event/{id}/cash-by-user` | `GET /api/v2/events/{id}/cashup/cash-by-user` |
| `PUT /api/cashups/event/{id}/person-cash/{id2}` | `PUT /api/v2/events/{id}/cashup/person-cash/{id2}` |
| `PUT /api/cashups/event/{id}/draft` | `PUT /api/v2/events/{id}/cashup/draft` |
| `POST /api/cashups/event/{id}/close` | `POST /api/v2/events/{id}/cashup/close` |
| `POST /api/cashups/event/{id}/reopen` | `POST /api/v2/events/{id}/cashup/reopen` |
| `POST /api/cashups/lines/{id}/pending` | `POST /api/v2/cashup-lines/{id}/pending` |
| `GET /api/{provider|yoco}-transactions` | `GET /api/v2/provider-transactions` |
| `GET /api/{provider|yoco}-transactions/unreconciled` | `GET /api/v2/provider-transactions/unreconciled` |
| `POST /api/{?:provider|yoco}-transactions/{id}/reconcile` | `POST /api/v2/provider-transactions/{?:provider|yoco}/reconcile` |
| `POST /api/{?:provider|yoco}-transactions/{id}/ignore` | `POST /api/v2/provider-transactions/{?:provider|yoco}/ignore` |
| `POST /api/waitlist` | `POST /api/v2/waitlist` |
| `GET /api/waitlist/mine` | `GET /api/v2/me/waitlist` |
| `GET /api/waitlist/claim/{id}` | `GET /api/v2/waitlist/claims/{id}` |
| `GET /api/waitlist/events/{id}` | `GET /api/v2/events/{id}/waitlist` |
| `POST /api/waitlist/{id}/{offer|move|remove}` | `POST /api/v2/waitlist/{id}/{offer|move|remove}` |
| `DELETE /api/waitlist/{id}` | `DELETE /api/v2/waitlist/{id}` |
| `GET /api/sponsorships/event/{id}` | `GET /api/v2/events/{id}/sponsorship` |
| `GET /api/sponsorships/event/{id}/pool` | `GET /api/v2/events/{id}/sponsorship/pool` |
| `POST /api/sponsorships/checkout` | `POST /api/v2/sponsorships/checkout` |
| `GET /api/consent/events/{id}` | `GET /api/v2/events/{id}/consent` |
| `GET /api/consent/events/{id}/medical` | `GET /api/v2/events/{id}/consent/medical` |
| `POST /api/consent/events/{id}/{resend-pending|rerequest}` | `POST /api/v2/events/{id}/consent/{resend-pending|rerequest}` |
| `POST /api/consent/requests/{id}/resend` | `POST /api/v2/consent-requests/{id}/resend` |
| `GET /api/consent/{id}` | `GET /api/v2/consent/{id}` |
| `POST /api/consent/{id}` | `POST /api/v2/consent/{id}` |
| `GET /api/forms/responses` | `GET /api/v2/form-responses` |
| `GET /api/feedback/summary` | `GET /api/v2/feedback/summary` |
| `GET /api/feedback/public/{id}` | `GET /api/v2/feedback-forms/{id}` |
| `POST /api/feedback/public/{id}` | `POST /api/v2/feedback-forms/{id}` |
| `GET /api/feedback/event/{id}` | `GET /api/v2/events/{id}/feedback` |
| `PUT /api/feedback/event/{id}` | `PUT /api/v2/events/{id}/feedback` |
| `DELETE /api/feedback/event/{id}` | `DELETE /api/v2/events/{id}/feedback` |
| `POST /api/feedback/event/{id}/send-now` | `POST /api/v2/events/{id}/feedback/send-now` |
| `GET /api/registrations` | `GET /api/v2/registrations` |
| `GET /api/registrations/myregistrations` | `GET /api/v2/me/registrations/full` |
| `GET /api/registrations/event/{id}` | `GET /api/v2/events/{id}/registrations` |
| `POST /api/registrations/manual` | `GET /api/v2/registrations/manual` |
| `POST /api/registrations/bulk` | `GET /api/v2/registrations/bulk` |
| `PUT /api/registrations/{id}/options` | `PUT /api/v2/registrations/{id}/items` |
| `POST /api/registrations/{id}/send-invoice` | `POST /api/v2/registrations/{id}/invoice` |
| `POST /api/registrations/{id}/coupon` | `POST /api/v2/registrations/{id}/coupon` |
| `DELETE /api/registrations/{id}/coupon` | `DELETE /api/v2/registrations/{id}/coupon` |
| `PUT /api/registrations/{id}/attendees` | `PUT /api/v2/registrations/{id}/attendees` |
| `POST /api/registrations/{id}/attendees/{id2}/send-form-link` | `POST /api/v2/registrations/{id}/attendees/{id2}/form-link` |
| `GET /api/registrations/{id}/forms/draft` | `GET /api/v2/registrations/{id}/form-draft` |
| `PUT /api/registrations/{id}/forms/draft` | `PUT /api/v2/registrations/{id}/form-draft` |
| `POST /api/registrations/{id}/forms/responses` | `POST /api/v2/registrations/{id}/form-responses` |
| `PUT /api/registrations/{id}/forms/responses` | `PUT /api/v2/registrations/{id}/form-responses` |
| `GET /api/registrations/{id}` | `GET /api/v2/registrations/{id}/full` |
| `PUT /api/registrations/{id}` | `PATCH /api/v2/registrations/{id}/status` |
| `GET /api/series` | `GET /api/v2/series` |
| `POST /api/series` | `POST /api/v2/series` |
| `GET /api/series/by-slug/{id}` | `GET /api/v2/series/by-slug/{id}` |
| `GET /api/series/{id}/compare` | `GET /api/v2/series/{id}/compare` |
| `POST /api/series/{id}/follow` | `PUT/DELETE /api/v2/series/{id}/follow` |
| `DELETE /api/series/{id}/follow` | `DELETE /api/v2/series/{id}/follow` |
| `PUT /api/series/{id}` | `PATCH /api/v2/series/{id}` |
| `DELETE /api/series/{id}` | `DELETE /api/v2/series/{id}` |
| `GET /api/season-passes/mine` | `GET /api/v2/me/season-passes` |
| `POST /api/season-passes/purchases/{id}/checkout` | `POST /api/v2/season-pass-purchases/{id}/checkout` |
| `POST /api/season-passes/{id}/buy` | `POST /api/v2/season-passes/{id}/buy` |
| `GET /api/season-passes` | `GET /api/v2/season-passes` |
| `POST /api/season-passes` | `POST /api/v2/season-passes` |
| `PUT /api/season-passes/{id}` | `PATCH /api/v2/season-passes/{id}` |
| `DELETE /api/season-passes/{id}` | `DELETE /api/v2/season-passes/{id}` |
| `POST /api/bundles/quote` | `PATCH/DELETE /api/v2/bundles/quote` |
| `GET /api/bundles/event/{id}` | `GET /api/v2/events/{id}/bundles` |
| `POST /api/bundles/event/{id}` | `POST /api/v2/events/{id}/bundles` |
| `PUT /api/bundles/{id}` | `PATCH /api/v2/bundles/{id}` |
| `DELETE /api/bundles/{id}` | `DELETE /api/v2/bundles/{id}` |
| `POST /api/coupons/validate` | `GET/PATCH /api/v2/coupons/check` |
| `GET /api/coupons/redemptions` | `GET /api/v2/coupons/redemptions` |
| `GET /api/coupons` | `GET /api/v2/coupons` |
| `POST /api/coupons` | `POST /api/v2/coupons` |
| `GET /api/coupons/{id}` | `GET /api/v2/coupons/{id}` |
| `PUT /api/coupons/{id}` | `PATCH /api/v2/coupons/{id}` |
| `GET /api/campuses` | `GET /api/v2/campuses` |
| `GET /api/campuses/admin` | `GET /api/v2/campuses/admin` |
| `GET /api/campuses/by-slug/{id}` | `GET /api/v2/campuses/by-slug/{id}` |
| `PUT /api/campuses/settings` | `PUT /api/v2/campuses/enabled` |
| `PUT /api/campuses/order` | `PUT /api/v2/campuses/order` |
| `POST /api/campuses` | `POST /api/v2/campuses` |
| `GET /api/campuses/{id}/audience` | `GET /api/v2/campuses/{id}/audience` |
| `PUT /api/campuses/{id}` | `PATCH /api/v2/campuses/{id}` |
| `DELETE /api/campuses/{id}` | `DELETE /api/v2/campuses/{id}` |
| `GET /api/users/profile` | `GET /api/v2/me` |
| `PUT /api/users/profile` | `PATCH /api/v2/me` |
| `POST /api/users` | `POST /api/v2/auth/signup` |
| `POST /api/users/login` | `POST /api/v2/auth/login` |
| `POST /api/users/login/2fa` | `POST /api/v2/auth/login/code` |
| `POST /api/registrations` | `POST /api/v2/registrations` |
| `DELETE /api/registrations/{id}` | `POST /api/v2/registrations/{id}/cancel` |
| `POST /api/payments/checkout` | `POST /api/v2/registrations/{id}/checkout` |
| `GET /api/tickets/scan-preview/{id}` | `GET /api/v2/checkin/{id}` |
| `POST /api/tickets/scan/{id}` | `POST /api/v2/checkin/{id}` |
+9 -4
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
@@ -107,7 +108,7 @@ curl -fsSLO https://c-git.crosscode.co.za/joshua/hope-events/raw/branch/main/scr
sudo bash install.sh --domain events.yourchurch.org --email you@yourchurch.org [--version v1.41.0]
```
It installs Node 20, PostgreSQL (with its own database and a random password), PM2, nginx (the
It installs Node 24 LTS, PostgreSQL (with its own database and a random password), PM2, nginx (the
API, uploads, `/health` and `/docs` to the backend, everything else to the site) and an HTTPS
certificate, writes `backend/.env` and `frontend/.env.production`, migrates, builds and starts the
app. Then open `https://<domain>/setup`: create the admin account and enter the **licence key**
@@ -137,7 +138,7 @@ set names that match your PM2 apps (`pm2 ls`) and the port you use.
2. Set all required environment variables (see backend/README.md).
3. Run Prisma migrations (if applicable).
4. Start the server with: npm start (or a process manager like PM2).
5. Expose the webhook endpoint /api/webhooks/yoco publicly and configure the YOCO webhook to point to it.
5. Expose the webhook endpoint /api/v2/webhooks/yoco publicly and register it with Yoco (Site settings → Payments → Yoco → Generate does this).
6. Ensure the uploads directory public/uploads exists and is writable.
### Frontend Deployment
@@ -155,7 +156,8 @@ set names that match your PM2 apps (`pm2 ls`) and the port you use.
## Useful Links
- Backend API Documentation: ./backend/API_DOCUMENTATION.md
- API reference: `/docs/v2` on the running backend (OpenAPI; admin token) — ./backend/API_DOCUMENTATION.md
- Moving to 2.0 (v1 API removed): ./MIGRATING-TO-2.md
- Backend README: ./backend/README.md
- Frontend README: ./frontend/README.md
- Site Settings — defaults reference: ./SETTINGS.md
@@ -163,6 +165,9 @@ set names that match your PM2 apps (`pm2 ls`) and the port you use.
## Recent Changes
> Older notes, kept for history. The `/api/…` paths they mention are API v1, removed in 2.0 —
> MIGRATING-TO-2.md lists each one's v2 replacement. Since 1.33 the changes are in CHANGELOG.md.
### Sold-out handling, tiered stock warnings & UI polish
**Backend**
+8 -4
View File
@@ -23,7 +23,7 @@ in-process settings cache TTL) without a server restart.
| Setting | Default when unset | Notes |
|---|---|---|
| `primary_color` | Legacy `accent_color` value if present, else `#4F46E5` (indigo) on the website / `#1e3a5f` (navy) in emails | See [Two different fallbacks](#two-different-fallbacks-for-primary_color) below — this is the one setting with a genuine inconsistency worth knowing about. Drives buttons, links, nav highlights, and the email header/CTA. |
| `primary_color` | Legacy `accent_color` value if present, else `#4F46E5` (indigo) on the website / `#1e3a5f` (navy) in emails | See [Two different fallbacks](#two-different-fallbacks-for-primary_color) below — this is the one setting with a genuine inconsistency worth knowing about. Drives buttons, links, nav highlights, and the email header/CTA. The Branding tab warns when it is under 4.5:1 against white (too light for white text and links) and offers a darker shade (1.50.0). |
| `secondary_color` | `210 40% 96.1%` background / near-black text (light gray "wash", shadcn's stock theme) | The Branding tab's color picker *pre-fills* `#8B5CF6` as a suggested starting point, but that's only a UI default — if you never hit Save, the actual site uses the light-gray fallback, not violet. |
| `accent_color` | `210 40% 96.1%` background / near-black text (same light gray as Secondary) | Same story as Secondary — the picker pre-fills `#EC4899` (pink) as a suggestion only. |
| `logo_url` | Bundled logo (`frontend/src/app/church_logo.jpg`) | Shown in the navbar. This is still the original Hope Family Church artwork checked into the repo — swap the file (or upload a real logo via the Branding tab) before deploying for a different organisation. |
@@ -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)
@@ -117,6 +117,10 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
| `retention_policies` | Inactive accounts 60 months (on), form answers 12, login history 24, payment details 24, children at kids check-in (`kids_profiles`) 6, audit/access logs 60 — all on | Admin → Privacy → Retention (JSON `{ policies: { key: { enabled, months } }, reviewedAt, reviewedBy }`). Minimums: inactive accounts and logs 12 months, login history and payment details 3, form answers and kids profiles 1; maximum 240. Saving marks them reviewed (readiness check). |
| `retention_last_run` | — | When the `retention` job last ran and what it did (readiness check: within 2 days). |
| `VOLUNTEER_JOBS_ENABLED` (env) | `true` | The `volunteers` job (every 15 minutes) that sends volunteer reminders the day before a shift. |
| `WEBHOOKS_JOBS_ENABLED` (env) | `true` | The `webhooks` job (every minute): sends deliveries that are due (retries), picks up sends cut off by a restart, deletes deliveries older than 30 days. New events are sent straight away without it. |
| `WEBHOOKS_ENABLED` (env) | `true` | `false` stops noticing events for outbound webhooks altogether (nothing new is queued). |
| `WEBHOOKS_ALLOW_PRIVATE` (env) | `false` | `true` lets webhook endpoints use http and private or local addresses — for local testing only (the e2e tests set it). |
| `SERIES_JOBS_ENABLED` (env) | `true` | The `series` job (every 15 minutes) that emails a series' followers once when a new event in it goes live (News & announcements: only people opted in to news). |
| `RETENTION_JOBS_ENABLED` (env) | `true` | The nightly 03:30 `retention` job. Off means nothing expires automatically. |
| `wawp_webhook_last_seen` | — | Set when WAWP's webhook arrives with the right secret; the readiness page uses it to check WhatsApp STOP is set up. |
| `privacy_notice_draft` | — | The unpublished draft from Admin → Privacy → Privacy notice (JSON). Published versions are in the `PrivacyNotice` table. With no published version the built-in notice (`backend/src/utils/privacyNotice.js`) is shown. The notice's org name, Information Officer, website, emails and medical retention days come from the settings above when it's shown. |
+267
View File
@@ -12,6 +12,273 @@ Write for church admins, not developers: no environment variables, endpoints or
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
-->
## 2.0.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
meant to go read-only (tickets still scan and all data can still be viewed and exported). Since
1.51.15 that wasn't being applied; it is again. Sites with an active licence see no change.
## 1.52.2
- **Safer updates.** If files on your server were changed by hand, an update now stops straight
away and says which files, instead of failing part-way. Nothing else changes.
## 1.52.1
- **Better documentation for developers.** If someone builds on your Cross Events site (for example
an integration or a website plug-in), the API reference now explains every part of it in full.
Nothing changes in how the site works.
## 1.52.0 — urgent
- **Paying at the door always gives people their tickets now.** On events with a required
attendee form, people registered at the door or in Manual registration were paid but got no
tickets, so they couldn't be checked in. The form now pops up to fill in with them — or tap
**Fill in later**: the tickets are issued straight away and they're sent a link to fill in the
form afterwards.
- **New: Corrections (for admins).** Pick any event, even one that's over, to see who's missing
tickets, still has a form to fill in or owes money. Issue all missing tickets in one click, give
someone their tickets now and let them pay later, make a ticket free, or check people in after
the event.
- The self-service kiosk asks for exactly the forms still needed, and shows headings on the form
as text instead of an empty box.
- People booked on several sessions now fill in one form each, not one per session.
## 1.51.17 — security update
- **Better protection for people's accounts.** The forgot-password and sign-in pages no longer
let anyone find out whether an email address has an account here.
## 1.51.16
- **The move to the new server connection is complete.** Payment confirmations from Yoco and
PayFast, WhatsApp messages coming in, and the calendar and unsubscribe links in emails now use
the new addresses. Links in emails you've already sent keep working.
## 1.51.15
- **The app now talks to the server only through its new version.** Privacy settings, the admin
pages, site settings, the Hub, webhooks and backups were the last to move. Everything works just
as before.
## 1.51.14
- **More groundwork for 2.0.** Accounts, messaging and broadcasts, WhatsApp set-up, the dashboards,
saved filters and report files now run on the new version of the server's connection to the
app. They work just as before.
## 1.51.13
- **More groundwork for 2.0.** Kids check-in and volunteer rosters now run on the new version of
the server's connection to the app. They work just as before.
## 1.51.12
- **More groundwork for 2.0.** Tickets, ticket transfers, offline scanning, the attendance register,
certificates and group allocations now run on the new version of the server's connection to the
app. They work just as before.
## 1.51.11
- **More groundwork for 2.0.** Payments, cash-ups, the waitlist, sponsorship, consent forms and
feedback now run on the new version of the server's connection to the app. They work just as
before.
## 1.51.10
- **More groundwork for 2.0.** Registrations — at the door, in the dashboards, attendee forms and
bulk actions — now run on the new version of the server's connection to the app. They work
just as before.
## 1.51.9
- **More groundwork for 2.0.** Event series, season passes, bundle deals, coupon codes and campuses
now run on the new version of the server's connection to the app. They work just as before.
## 1.51.8
- **More groundwork for 2.0.** Event pages, ticket types, scanning sections, attachments, planned
costs and picture uploads now run on the new version of the server's connection to the app. They
work just as before.
## 1.51.7
- **More groundwork for 2.0.** Creating and editing events, and every list of events staff choose
from, now run on the new version of the server's connection to the app. They work as before.
- The background job runner was updated. Its list of recent runs (Site settings → Background jobs)
starts afresh with this version.
## 1.51.6
- **Find anything with Ctrl+K.** The search box (Ctrl+K) now lists every report, and pages that
were missing: the dashboard, all reports, ticket options, your groups, and your own events,
payments, privacy, kids, serving, forms and donations.
- Excel downloads use a newer, maintained library. They look the same.
## 1.51.5
- **Updated framework.** The site now runs on the latest version of its web framework. Nothing
changes in how it works.
## 1.51.4 — security
- **Security updates** to the parts the site is built on, including a critical fix in the web
framework. Recommended for everyone.
## 1.51.3
- **More groundwork for 2.0.** The ticket scanner and check-in at the door now run on the new version
of the server's connection to the app, and the server's web framework has been updated. They work
just as before.
## 1.51.2
- **More groundwork for 2.0.** Logging in, signing up and changing your details now run on the
new version of the server's connection to the app. They work just as before.
## 1.51.1
- **More groundwork for 2.0.** Registering, paying online and cancelling now run on the new
version of the server's connection to the app. They work just as before.
## 1.51.0
- **Groundwork for Cross Events 2.0.** Behind the scenes, the server is moving to a newer, safer
way of talking to the app, ready for the AI helpers in 2.0. Nothing changes in how you use
Cross Events.
## 1.50.2
- **Finding an event quickly.** Wherever staff and supervisors choose an event (the scanner, at
the door, payments, messages, forms, attendance and more) there is now a search box. The list
opens on upcoming events, past ones are one click away, and typing finds any event by name,
campus or date. The events list in Reports can be searched too.
- **Webhook guide.** Site settings → Webhooks has a "How webhooks work" guide for whoever connects
another system: what is sent and when, how to check it came from you, and an example message for
every kind of event.
## 1.50.1
- **Finding things in long lists.** The audit log can be searched by person, what was changed or
IP address, filtered by role, and its action list now includes every kind of action. Data
requests can be searched and come a page at a time, and the webhook delivery log can show only
failed deliveries and load older ones.
- **Fix:** the audit log's "To" date now includes that day.
- **Faster Payments page** for churches with a few years of payments behind them.
## 1.50.0
- **Easier for everyone to use.** The public pages, registration, payment, My Events and the
self-service kiosk now work properly with a keyboard and with screen readers: a "Skip to main
content" link, a clear outline showing where you are, popups that can be closed with Escape, and
every form field, button and error message read out correctly. Faint grey text is darker and
links in sentences are underlined, so they're easier to read.
- **Branding check.** If the main colour you pick in Site settings → Branding is too light for
white text to be readable, you'll see a warning and can switch to a darker shade with one click.
## 1.49.2
- **Saved filters.** On Manage registrations, Users, Events and Reports, save the filters you use
under a name and get back to them with one click. Star one and it's ready every time you open the
page. They're yours and follow you to any device.
## 1.49.1
- **Do many at once.** On Manage registrations, tick the people you want (or select everyone
shown after filtering) and send them a message, mark them paid, move them to another ticket, put
them in a room or group, export them to Excel, or cancel them.
- Moving people to another ticket keeps their tickets and QR codes; the price follows the new
ticket, so any difference shows as owing.
- You see what happened to each one, and anything that couldn't be done says why.
## 1.49.0
- **Today, at a glance.** The dashboard now starts with what's on today: each event with how many
people are checked in, children in at kids church, and volunteers here — plus money in today and
this week.
- **Waiting for you.** One list of what needs someone — unpaid registrations, consent forms still
outstanding, waitlists, volunteers asking for cover, events to cash up — each a click away.
- **Ctrl K to find anything.** Press Ctrl K anywhere in the dashboard to jump to a page or setting,
start "New event", or find an event, a payment by amount ("R450") or a person.
## 1.48.2
- No changes for churches in this version — it adds demo data for showing Cross Events to other
churches.
## 1.48.1
- **Privacy notice brought up to date** with volunteer rosters, event series, season passes and
webhooks. If you haven't published your own version, your site shows the new text straight away.
- **Published your own version?** Admin → Privacy → Privacy notice now shows what changed in the
built-in text, section by section, so you can take the new wording with one click and publish.
- **Everyone agrees to the notice once.** People whose account was made for them agree when they
activate it, and anyone who never has is asked once after they next log in.
## 1.48.0
- **Webhooks.** Connect Cross Events to other systems — ChurchSuite, Planning Center, a Google
Sheet through Zapier or Make. Choose what to send (new registrations, payments, check-ins, new
accounts, published events) in Site settings → Webhooks, and it goes out as it happens.
- Every message is signed so the other system can trust it, failures are tried again
automatically, and a delivery log shows what was sent.
- Each webhook is added to your operator register for POPIA. Children's details are never sent.
## 1.47.4
- **Asked for an update, and a newer one came out?** The Licence page now shows which version you
asked for, tells you when a newer one is out, and lets you ask for that one instead with one
button.
## 1.47.3
- **Season passes.** Sell a ticket for several events in a series at one price — say all of next
year's breakfasts. People buy it on the series page, pay once, and get a ticket for each event.
- The money is shared out over the events in line with their normal ticket prices, so each event's
cashup shows its part.
- Set passes up on the Event series page. A pass stops selling when its first event starts.
## 1.47.2
- **Compare a series year on year.** A new report puts the events in a series side by side —
registrations, tickets, income and how many came — with how much each went up or down from the
one before. Open it from Reports, or with **Compare** on the Event series page.
## 1.47.1
- **Event series.** Group events that come back — every year's camp, the monthly breakfast — on
one page that shows what's coming up and what's been. Each event in a series links to the next one.
- **Follow a series.** People can follow a series and get an email when the next event in it is
out. It counts as news, so only people who chose to get news and announcements receive it.
- A copied event stays in its series, and a series can have events at different campuses.
## 1.47.0
- **Copy an event.** Running the same camp, course or breakfast again? Press **Copy** on the event
+5
View File
@@ -77,6 +77,11 @@ WAITLIST_JOB_ENABLED=true
PAYMENT_STATUS_JOB_ENABLED=true
# Volunteer rosters: reminders the day before a shift (every 15 minutes)
VOLUNTEER_JOBS_ENABLED=true
SERIES_JOBS_ENABLED=true
WEBHOOKS_JOBS_ENABLED=true
WEBHOOKS_ENABLED=true
# Local testing only: lets webhook endpoints be http:// or on a private network
WEBHOOKS_ALLOW_PRIVATE=false
# Privacy (POPIA): daily 08:00 reminder to admins of data requests due within a week
PRIVACY_JOBS_ENABLED=true
# Privacy (POPIA): nightly 03:30 retention job (Admin → Privacy → Retention). Note: inactive
File diff suppressed because it is too large Load Diff
+21 -8
View File
@@ -1,10 +1,19 @@
# Demo data
`npm run set-demo` replaces everything in the database with a realistic set of demo data,
for showing the platform to other churches. It stages six upcoming events with
registrations, payments, tickets and people, and ten completed events from the past year
with door sales, check-ins, feedback and cashups. That way every dashboard, report,
check-in and cashup screen has something to show.
The demo seeds replace everything in the database with a realistic set of demo data, for
showing the platform to other churches. Pick the one that looks like the church you're
showing it to:
| Command (repo root) | For | What's in it |
|---|---|---|
| `npm run demo:small` | A small church, one congregation | 3 upcoming events (a sold-out breakfast with a waitlist, a family day, free carols), 4 past events with check-ins and cashups. No campuses, kids check-in, rosters, courses or series. |
| `npm run demo:events` | Events and conferences (the original demo) | 6 upcoming events (coupons, variants, early birds, a youth camp with consent and cabins, bundles, donations) and 10 past events with every kind of cashup and funnel analytics. |
| `npm run demo:ministry` | One campus with weekly ministry | The Alpha Course running now (sessions, attendance, late joining, certificates), a finished Marriage Course with certificates, a 3-day conference sold by day, **Kids Church** with families, children, rooms and check-ins, **volunteer rosters**, a **Men's Breakfast series** with followers and a **season pass**, plus a family day, women's conference and carols. |
| `npm run demo:multicampus` | A church with several campuses | **Campuses** Central, North and South (switched on), events per campus and church-wide, home campuses, staff limited to a campus (Anri Botha → North, Daniel Pillay → South), the breakfast series moving between campuses, kids church and rosters at two campuses, and all the events-demo events spread over the campuses. |
`npm run demo:list` prints the same list. Kids church always has a session **today** with
children checked in right now, so the kids desk and room screens have something live to
show on any day.
> **This deletes real data.** Run it only against a database you're happy to wipe, such
> as a dedicated demo deployment. It is not something to run on a live church site.
@@ -14,11 +23,15 @@ check-in and cashup screen has something to show.
## Running it
```bash
cd backend
npm run set-demo # shows the database it will wipe and asks you to type "demo"
npm run set-demo -- --yes # no prompt (for scripts)
npm run demo:ministry # (repo root) shows the database it will wipe and asks you to type "demo"
npm run demo:ministry -- --yes # no prompt (for scripts)
# or in backend/:
npm run set-demo -- --profile=ministry --yes # without --profile it seeds "events"
```
The campuses setting is switched on for `multicampus` and off for the others (site settings
are otherwise kept). **Restart the backend afterwards** so it picks the setting up.
The script uses `DATABASE_URL` from `backend/.env`. To point it at another database
for one run, set it in the environment first. A value already in the environment wins
over `.env`:
+13 -4
View File
@@ -145,21 +145,29 @@ npm run prisma:generate
### Demo data
`npm run set-demo` wipes the database (keeping site settings and the admin account) and stages six upcoming demo events with registrations, payments and tickets. See [DEMO_DATA.md](DEMO_DATA.md).
`npm run set-demo -- --profile=<small|events|ministry|multicampus>` wipes the database (keeping site settings and the admin account) and stages demo data for that kind of church; from the repo root it's `npm run demo:<name>` (`npm run demo:list` describes them). See [DEMO_DATA.md](DEMO_DATA.md).
---
## Running the Server
```bash
# Development (auto-reload)
# Development (auto-reload; runs the TypeScript in src/ through tsx)
npm run dev
# Production
# Production: build once per update, then start (src/index.js runs the build in dist/)
npm run build
npm start
# Type-check only
npm run typecheck
```
Visit `/` for the status page. Visit `/docs?token=<admin-jwt>` for full API documentation (admin only).
Since 1.51.0 the backend is partly TypeScript (`allowJs`: `.js` compiles as it is, `.ts` is checked
strictly). `src/index.js` is a small launcher: with plain Node it runs `dist/` when `dist/build.json`
matches this version, otherwise it compiles on the fly with tsx. The server itself is `src/app.js`.
Visit `/` for the status page. Visit `/docs?token=<admin-jwt>` for the v1 API documentation and `/docs/v2` for the v2 (OpenAPI) reference — both admin only.
---
@@ -532,6 +540,7 @@ Setup: Admin → Site Settings → **Yoco**. Paste the secret key (checked with
### PM2 (recommended)
```bash
npm install -g pm2
npm run build
pm2 start src/index.js --name hope-events-api
pm2 save && pm2 startup
```
+5 -1
View File
@@ -1,4 +1,8 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['**/tests/**/*.test.js'],
testMatch: ['**/tests/**/*.test.js', '**/tests/**/*.test.ts'],
// TypeScript (src/**/*.ts and tests) runs through ts-jest; plain JS is left as it is.
transform: { '^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.json' }] },
moduleFileExtensions: ['ts', 'js', 'json'],
modulePathIgnorePatterns: ['<rootDir>/dist/'],
};
+1632 -1388
View File
File diff suppressed because it is too large Load Diff
+34 -13
View File
@@ -1,11 +1,11 @@
{
"name": "event-management-backend",
"version": "1.47.0",
"version": "2.0.1",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"dev": "tsx watch src/index.js",
"test": "jest",
"postinstall": "prisma generate",
"prisma:generate": "prisma generate",
@@ -14,34 +14,50 @@
"prisma:reset": "prisma migrate reset --force",
"migrate:env-to-db": "node scripts/migrate-env-to-db.js",
"migrate:env-to-db:dry": "node scripts/migrate-env-to-db.js --dry-run",
"set-demo": "node scripts/set-demo.js"
"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",
"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",
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"cron-parser": "^4.9.0",
"cron-parser": "^5.10.2",
"dotenv": "^16.3.1",
"exceljs": "^4.4.0",
"express": "^4.18.2",
"express": "^5.3.0",
"express-rate-limit": "^8.6.2",
"ics": "^3.12.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.2.0",
"node-fetch": "^2.7.0",
"nodemailer": "^10.0.16",
"multer": "^2.5.0",
"nodemailer": "^10.3.0",
"pdfkit": "^0.17.1",
"pg-boss": "^10.4.2",
"pg-boss": "^11.1.2",
"qrcode": "^1.5.4",
"raw-body": "^3.0.0",
"uuid": "^11.1.1"
"swagger-ui-dist": "^5.34.0",
"tsx": "^4.23.15",
"typescript": "^5.9.3",
"uuid": "^11.1.1",
"zod": "^4.6.7"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.19.43",
"@types/supertest": "^7.2.1",
"@types/swagger-ui-dist": "^3.30.6",
"jest": "^30.4.2",
"prisma": "^7.10.1"
"prisma": "^7.10.1",
"supertest": "^7.3.1",
"ts-jest": "^29.4.15"
},
"overrides": {
"uuid": "^11.1.1",
@@ -54,6 +70,11 @@
"prisma@7.10.1": true,
"@prisma/engines@7.10.1": true,
"unrs-resolver@1.12.2": true,
"@parcel/watcher": false
"@parcel/watcher": false,
"@scarf/scarf": false,
"esbuild": false
},
"engines": {
"node": ">=22"
}
}
@@ -0,0 +1,59 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AdminAuditAction" ADD VALUE 'series_created';
ALTER TYPE "AdminAuditAction" ADD VALUE 'series_updated';
ALTER TYPE "AdminAuditAction" ADD VALUE 'series_deleted';
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "seriesAnnouncedAt" TIMESTAMP(3),
ADD COLUMN "seriesId" TEXT;
-- CreateTable
CREATE TABLE "EventSeries" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"picture" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EventSeries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SeriesFollow" (
"id" TEXT NOT NULL,
"seriesId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SeriesFollow_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "EventSeries_slug_key" ON "EventSeries"("slug");
-- CreateIndex
CREATE INDEX "SeriesFollow_userId_idx" ON "SeriesFollow"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "SeriesFollow_seriesId_userId_key" ON "SeriesFollow"("seriesId", "userId");
-- CreateIndex
CREATE INDEX "Event_seriesId_idx" ON "Event"("seriesId");
-- AddForeignKey
ALTER TABLE "Event" ADD CONSTRAINT "Event_seriesId_fkey" FOREIGN KEY ("seriesId") REFERENCES "EventSeries"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeriesFollow" ADD CONSTRAINT "SeriesFollow_seriesId_fkey" FOREIGN KEY ("seriesId") REFERENCES "EventSeries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeriesFollow" ADD CONSTRAINT "SeriesFollow_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,102 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'season_pass_changed';
-- AlterTable
ALTER TABLE "Payment" ADD COLUMN "seasonPassPurchaseId" TEXT;
-- AlterTable
ALTER TABLE "Registration" ADD COLUMN "seasonPassPurchaseId" TEXT;
-- CreateTable
CREATE TABLE "SeasonPass" (
"id" TEXT NOT NULL,
"seriesId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"price" DOUBLE PRECISION NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"saleEndsAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SeasonPass_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SeasonPassItem" (
"id" TEXT NOT NULL,
"passId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"eventOptionId" TEXT NOT NULL,
"variantId" TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "SeasonPassItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SeasonPassPurchase" (
"id" TEXT NOT NULL,
"passId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"total" DOUBLE PRECISION NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"checkoutId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SeasonPassPurchase_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SeasonPass_seriesId_idx" ON "SeasonPass"("seriesId");
-- CreateIndex
CREATE INDEX "SeasonPassItem_eventId_idx" ON "SeasonPassItem"("eventId");
-- CreateIndex
CREATE INDEX "SeasonPassItem_eventOptionId_idx" ON "SeasonPassItem"("eventOptionId");
-- CreateIndex
CREATE INDEX "SeasonPassItem_variantId_idx" ON "SeasonPassItem"("variantId");
-- CreateIndex
CREATE UNIQUE INDEX "SeasonPassItem_passId_eventId_key" ON "SeasonPassItem"("passId", "eventId");
-- CreateIndex
CREATE INDEX "SeasonPassPurchase_passId_idx" ON "SeasonPassPurchase"("passId");
-- CreateIndex
CREATE INDEX "SeasonPassPurchase_userId_idx" ON "SeasonPassPurchase"("userId");
-- CreateIndex
CREATE INDEX "SeasonPassPurchase_checkoutId_idx" ON "SeasonPassPurchase"("checkoutId");
-- AddForeignKey
ALTER TABLE "SeasonPass" ADD CONSTRAINT "SeasonPass_seriesId_fkey" FOREIGN KEY ("seriesId") REFERENCES "EventSeries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeasonPassItem" ADD CONSTRAINT "SeasonPassItem_passId_fkey" FOREIGN KEY ("passId") REFERENCES "SeasonPass"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeasonPassItem" ADD CONSTRAINT "SeasonPassItem_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeasonPassItem" ADD CONSTRAINT "SeasonPassItem_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeasonPassItem" ADD CONSTRAINT "SeasonPassItem_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeasonPassPurchase" ADD CONSTRAINT "SeasonPassPurchase_passId_fkey" FOREIGN KEY ("passId") REFERENCES "SeasonPass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeasonPassPurchase" ADD CONSTRAINT "SeasonPassPurchase_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Registration" ADD CONSTRAINT "Registration_seasonPassPurchaseId_fkey" FOREIGN KEY ("seasonPassPurchaseId") REFERENCES "SeasonPassPurchase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_seasonPassPurchaseId_fkey" FOREIGN KEY ("seasonPassPurchaseId") REFERENCES "SeasonPassPurchase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,54 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'webhook_endpoint_changed';
-- CreateTable
CREATE TABLE "WebhookEndpoint" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"url" TEXT NOT NULL,
"secret" TEXT NOT NULL,
"events" TEXT[],
"isActive" BOOLEAN NOT NULL DEFAULT true,
"operatorId" TEXT,
"lastSuccessAt" TIMESTAMP(3),
"lastFailureAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WebhookEndpoint_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WebhookDelivery" (
"id" TEXT NOT NULL,
"endpointId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"attempts" INTEGER NOT NULL DEFAULT 0,
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastStatusCode" INTEGER,
"lastError" TEXT,
"deliveredAt" TIMESTAMP(3),
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WebhookDelivery_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "WebhookDelivery_status_nextAttemptAt_idx" ON "WebhookDelivery"("status", "nextAttemptAt");
-- CreateIndex
CREATE INDEX "WebhookDelivery_endpointId_createdAt_idx" ON "WebhookDelivery"("endpointId", "createdAt");
-- CreateIndex
CREATE INDEX "WebhookDelivery_userId_idx" ON "WebhookDelivery"("userId");
-- CreateIndex
CREATE INDEX "WebhookDelivery_createdAt_idx" ON "WebhookDelivery"("createdAt");
-- AddForeignKey
ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_endpointId_fkey" FOREIGN KEY ("endpointId") REFERENCES "WebhookEndpoint"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'registrations_bulk';
@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "SavedFilter" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"page" TEXT NOT NULL,
"name" TEXT NOT NULL,
"params" JSONB NOT NULL,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SavedFilter_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SavedFilter_userId_page_idx" ON "SavedFilter"("userId", "page");
-- CreateIndex
CREATE UNIQUE INDEX "SavedFilter_userId_page_name_key" ON "SavedFilter"("userId", "page", "name");
-- AddForeignKey
ALTER TABLE "SavedFilter" ADD CONSTRAINT "SavedFilter_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,28 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'ai_settings_changed';
-- CreateTable
CREATE TABLE "AiCall" (
"id" TEXT NOT NULL,
"userId" TEXT,
"helper" TEXT NOT NULL,
"outcome" TEXT NOT NULL,
"chargedCents" DOUBLE PRECISION NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AiCall_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AiCall_createdAt_idx" ON "AiCall"("createdAt");
-- CreateIndex
CREATE INDEX "AiCall_userId_createdAt_idx" ON "AiCall"("userId", "createdAt");
-- AddForeignKey
ALTER TABLE "AiCall" ADD CONSTRAINT "AiCall_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- 2.0 AI helpers: Anthropic in the operator register, off until the site uses AI.
INSERT INTO "Operator" ("id", "name", "purpose", "dataShared", "location", "website", "active", "order", "updatedAt") VALUES
(gen_random_uuid()::text, 'Anthropic (Claude), through the software provider''s Hub', 'AI helpers for staff: drafting events and messages, improving text, summarising reports and feedback', 'The text a staff member asks to draft or improve, report figures, and feedback comments with names and contact details removed. Not kept by the software provider; Anthropic''s commercial terms apply', 'United States', 'https://www.anthropic.com/legal/privacy', false, 9, CURRENT_TIMESTAMP);
@@ -0,0 +1,12 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'attendee_form_waived';
ALTER TYPE "AdminAuditAction" ADD VALUE 'tickets_issued_override';
ALTER TYPE "AdminAuditAction" ADD VALUE 'registration_complimentary';
ALTER TYPE "AdminAuditAction" ADD VALUE 'checkin_corrected';
-- AlterTable
ALTER TABLE "Registration" ADD COLUMN "formWaivedAt" TIMESTAMP(3),
ADD COLUMN "formWaivedById" TEXT,
ADD COLUMN "ticketsReleasedAt" TIMESTAMP(3),
ADD COLUMN "ticketsReleasedById" TEXT,
ADD COLUMN "complimentaryDiscount" DOUBLE PRECISION NOT NULL DEFAULT 0;
+214
View File
@@ -162,6 +162,10 @@ model User {
volunteerSlots VolunteerSlot[] @relation("VolunteerSlotUser")
volunteersAssigned VolunteerSlot[] @relation("VolunteerSlotAssignedBy")
volunteersCheckedIn VolunteerSlot[] @relation("VolunteerSlotCheckedInBy")
seriesFollows SeriesFollow[]
seasonPassPurchases SeasonPassPurchase[]
savedFilters SavedFilter[]
aiCalls AiCall[]
swapsAsked VolunteerSwap[] @relation("VolunteerSwapFrom")
swapsTaken VolunteerSwap[] @relation("VolunteerSwapTo")
@@ -319,10 +323,109 @@ model Event {
volunteerOptionId String?
volunteerCheckInToken String? @unique
// Event series (1.47.1): separate events grouped on one series page (Camp 2025, Camp 2026).
// seriesAnnouncedAt: when followers were emailed that this event is out (once, when it goes live).
seriesId String?
series EventSeries? @relation(fields: [seriesId], references: [id], onDelete: SetNull)
seasonPassItems SeasonPassItem[]
seriesAnnouncedAt DateTime?
@@index([createdById])
@@index([closedById])
@@index([reopenedById])
@@index([campusId])
@@index([seriesId])
}
/// Separate events grouped together, e.g. every year's camp or the monthly men's breakfast.
/// Not tied to a campus: a series can have events at several campuses.
model EventSeries {
id String @id @default(uuid())
name String
slug String @unique
description String?
picture String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
events Event[]
followers SeriesFollow[]
passes SeasonPass[]
}
/// Someone who wants to hear when a new event in the series is out (sent as news).
model SeriesFollow {
id String @id @default(uuid())
seriesId String
userId String
createdAt DateTime @default(now())
series EventSeries @relation(fields: [seriesId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([seriesId, userId])
@@index([userId])
}
/// A season pass (1.47.3): one price for a ticket at each of several events in a series. Buying
/// it registers the buyer for every event on it in one checkout; the price is split across the
/// events in proportion to their normal ticket prices (for each event's cashup).
model SeasonPass {
id String @id @default(uuid())
seriesId String
name String
description String?
price Float
isActive Boolean @default(true)
// Stops selling then (it always stops when the first event on it starts).
saleEndsAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
series EventSeries @relation(fields: [seriesId], references: [id], onDelete: Cascade)
items SeasonPassItem[]
purchases SeasonPassPurchase[]
@@index([seriesId])
}
/// One event on a pass: which ticket (and variant) the pass gives there.
model SeasonPassItem {
id String @id @default(uuid())
passId String
eventId String
eventOptionId String
variantId String?
order Int @default(0)
pass SeasonPass @relation(fields: [passId], references: [id], onDelete: Cascade)
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
@@unique([passId, eventId])
@@index([eventId])
@@index([eventOptionId])
@@index([variantId])
}
/// Someone's purchase of a pass: `quantity` passes, one registration per event.
model SeasonPassPurchase {
id String @id @default(uuid())
passId String
userId String
quantity Int
total Float
// pending | paid | cancelled
status String @default("pending")
// The online checkout started for it (our ProviderCheckout id).
checkoutId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
pass SeasonPass @relation(fields: [passId], references: [id], onDelete: Restrict)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
registrations Registration[]
payments Payment[]
@@index([passId])
@@index([userId])
@@index([checkoutId])
}
model EventOption {
@@ -346,6 +449,7 @@ model EventOption {
couponScopes CouponOption[]
waitlistEntries WaitlistEntry[]
bundleItems EventBundleItem[]
seasonPassItems SeasonPassItem[]
@@index([eventId])
}
@@ -382,6 +486,7 @@ model OptionVariant {
couponScopes CouponOption[]
waitlistEntries WaitlistEntry[]
bundleItems EventBundleItem[]
seasonPassItems SeasonPassItem[]
@@index([eventOptionId])
}
@@ -401,6 +506,15 @@ model Registration {
bundleDiscount Float @default(0)
// Where named attendees' tickets go: all to the payer, or each to the attendee.
ticketDelivery TicketDelivery @default(payer)
// Corrections (1.52.0). The attendee form can come later: tickets don't wait for it ("Fill in
// later" at the door or in Manual registrations, or an admin in Corrections).
formWaivedAt DateTime?
formWaivedById String?
// An admin issued the tickets before it was paid ("pay later"): the balance is still owed.
ticketsReleasedAt DateTime?
ticketsReleasedById String?
// A balance an admin wrote off ("make it free"); part of discountTotal (utils/coupons.js).
complimentaryDiscount Float @default(0)
analyticsSessionId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -412,6 +526,10 @@ model Registration {
formDrafts FormDraft[]
couponRedemptions CouponRedemption[]
bundleApplications RegistrationBundle[]
// Season pass (1.47.3): one registration per event on the pass, its line priced at that
// event's share of the pass price (never repriced, no coupons, options not editable).
seasonPassPurchaseId String?
seasonPassPurchase SeasonPassPurchase? @relation(fields: [seasonPassPurchaseId], references: [id], onDelete: SetNull)
attendees Attendee[]
waitlistEntries WaitlistEntry[]
volunteerSlots VolunteerSlot[]
@@ -502,6 +620,10 @@ model Payment {
originalPayment Payment? @relation("SplitPayments", fields: [originalPaymentId], references: [id], onDelete: SetNull)
splitPayments Payment[] @relation("SplitPayments")
providerTransactions ProviderTransaction[]
// Season pass: one provider payment is recorded as one Payment per event on the pass (its
// share), all linked to the purchase. Only the first carries the provider's externalId.
seasonPassPurchaseId String?
seasonPassPurchase SeasonPassPurchase? @relation(fields: [seasonPassPurchaseId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([recordedById])
@@ -666,6 +788,10 @@ enum AdminAuditAction {
event_sessions_changed
attendance_marked
attendance_unmarked
attendee_form_waived
tickets_issued_override
registration_complimentary
checkin_corrected
certificate_issued
certificate_revoked
job_run_manual
@@ -702,6 +828,13 @@ enum AdminAuditAction {
volunteer_assigned
volunteer_removed
volunteer_checked_in_manual
series_created
series_updated
series_deleted
season_pass_changed
webhook_endpoint_changed
registrations_bulk
ai_settings_changed
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
@@ -2134,3 +2267,84 @@ model VolunteerSwap {
@@index([roleId, status])
@@index([slotId])
}
/// Outbound webhooks (1.48.0): a URL that is sent what happens here (registrations, payments,
/// check-ins, new accounts, published events), signed with its own secret.
model WebhookEndpoint {
id String @id @default(uuid())
name String
url String
// HMAC secret, encrypted (utils/encryption.js); shown to the admin once, when made or rotated.
secret String
// Event types it gets, e.g. ["registration.paid", "ticket.checked_in"].
events String[]
isActive Boolean @default(true)
// The operator register entry made for it (POPIA), if any.
operatorId String?
lastSuccessAt DateTime?
lastFailureAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deliveries WebhookDelivery[]
}
/// One message to one endpoint, with its retries. Kept 30 days.
model WebhookDelivery {
id String @id @default(uuid())
endpointId String
// The event's own id (the same for every endpoint it went to) and type.
eventId String
type String
payload Json
// pending | sending | retrying | succeeded | failed
status String @default("pending")
attempts Int @default(0)
nextAttemptAt DateTime @default(now())
lastStatusCode Int?
lastError String?
deliveredAt DateTime?
// Whose personal information is in it, so it can be removed with their account.
userId String?
createdAt DateTime @default(now())
endpoint WebhookEndpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade)
@@index([status, nextAttemptAt])
@@index([endpointId, createdAt])
@@index([userId])
@@index([createdAt])
}
/// A person's saved filters on a list page (1.49.2): `page` is the list (registrations, users,
/// events, reports), `params` the filter values. At most one default per person and page.
model SavedFilter {
id String @id @default(uuid())
userId String
page String
name String
params Json
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@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);
});
+22
View File
@@ -0,0 +1,22 @@
/**
* Writes the /api/v2 OpenAPI document to frontend/src/lib/api-v2/openapi.json, from which the
* frontend's types are generated (`npm run api:types` in the repo root does both). Run it after
* adding or changing a v2 route; tests/v2.test.ts fails while the saved copy is out of date.
*/
import 'dotenv/config';
import fs from 'fs';
import path from 'path';
import { buildOpenApi } from '../src/v2/openapi';
import '../src/v2';
export const OPENAPI_FILE = path.join(__dirname, '..', '..', 'frontend', 'src', 'lib', 'api-v2', 'openapi.json');
// The version is left out so a release bump doesn't change the file.
export const currentDocument = () => JSON.stringify(buildOpenApi({ version: 'v2' }), null, 2) + '\n';
if (require.main === module) {
fs.mkdirSync(path.dirname(OPENAPI_FILE), { recursive: true });
fs.writeFileSync(OPENAPI_FILE, currentDocument());
console.log(`Wrote ${path.relative(process.cwd(), OPENAPI_FILE)}`);
process.exit(0);
}
+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 = [
+600 -21
View File
@@ -2,9 +2,19 @@
* set-demo.js
*
* Replaces the database contents with demo data for showing the system to other
* churches: six upcoming events with registrations, payments, tickets, attendee forms,
* coupons, a waitlist, camp cabins, sponsorships, costs and funnel analytics, plus ten
* completed events from the past year with door sales, check-ins, feedback and cashups.
* churches. Four profiles, for different kinds of church (see PROFILES below, or
* `npm run demo:list`):
*
* small One congregation, the essentials: a few events, payments, check-ins, cashups.
* events Events and conferences (the original demo, the default): six upcoming events
* with coupons, a waitlist, camp cabins, sponsorships, bundles, costs and funnel
* analytics, and ten completed events with door sales, feedback and cashups.
* ministry One campus with weekly ministry: a multi-session course (attendance and
* certificates), a "pick your days" conference, kids church with kids check-in,
* volunteer rosters, and a Men's Breakfast series with followers and a season pass.
* multicampus Three campuses: events per campus and church-wide, home campuses,
* campus-limited staff, a series across campuses, kids check-in and rosters at two
* campuses.
*
* Everything is removed first EXCEPT the site settings (AppSetting) and the admin
* account (DEMO_ADMIN_EMAIL, default joshua@crosscode.co.za). Dates are relative to
@@ -13,10 +23,14 @@
* No emails or WhatsApp messages are sent. Demo people use @example.com addresses and
* have no phone numbers, so later reminders can't reach a real person.
*
* Usage:
* cd backend
* npm run set-demo (asks for confirmation)
* npm run set-demo -- --yes (no prompt)
* Campuses are switched on (site setting) for "multicampus" and off for the others; restart the
* backend afterwards so it picks the setting up.
*
* Usage (from the repo root):
* npm run demo:small | demo:events | demo:ministry | demo:multicampus (asks for confirmation)
* npm run demo:list (what each one has)
* or in backend/:
* npm run set-demo -- --profile=ministry --yes (no prompt)
*/
'use strict';
@@ -28,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');
@@ -38,6 +52,8 @@ const prisma = createPrismaClient();
const ADMIN_EMAIL = (process.env.DEMO_ADMIN_EMAIL || 'joshua@crosscode.co.za').trim().toLowerCase();
const DEMO_PASSWORD = 'Demo@1234';
const SKIP_PROMPT = process.argv.includes('--yes') || process.argv.includes('-y');
const PROFILE_NAME = (process.argv.find(a => a.startsWith('--profile=')) || '--profile=events').split('=')[1].trim().toLowerCase();
const LIST_ONLY = process.argv.includes('--list');
// Not truncated: migrations, site settings, users (all but the admin are deleted
// separately) and the admin's own account-security rows.
@@ -161,13 +177,16 @@ const add = (model, row) => {
};
const INSERT_ORDER = [
'user', 'event', 'eventOption', 'optionVariant', 'earlyBirdTier', 'section', 'sectionOption',
'campus', 'user', 'eventSeries', 'event', 'eventSession', 'eventOption', 'optionVariant', 'earlyBirdTier', 'section', 'sectionOption',
'eventForm', 'eventFormField', 'coupon', 'couponEvent', 'eventBundle', 'eventBundleItem',
'seasonPass', 'seasonPassItem', 'seasonPassPurchase',
'registration', 'registrationOption', 'registrationOptionTranche', 'attendee', 'payment', 'ticket',
'ticketUsage', 'couponRedemption', 'registrationBundle',
'formResponse', 'formAnswer', 'consentRequest', 'waitlistEntry', 'allocationSet',
'allocationGroup', 'allocationAssignment', 'eventCost', 'feedbackForm', 'feedbackField',
'feedbackInvite', 'feedbackResponse', 'feedbackAnswer', 'analyticsEvent', 'analyticsDaily',
'certificate', 'seriesFollow', 'family', 'familyMember', 'child', 'authorisedAdult',
'kidsPickupCode', 'kidsCheckIn', 'kidsRoomDuty', 'servingRole', 'volunteerSlot', 'volunteerSwap',
];
// Extra facts about demo people that aren't columns (kept out of the inserted rows).
@@ -314,6 +333,8 @@ function addRegistration(event, user, lines, { createdAt, pay = 'full', method =
quantity: l.quantity,
priceSnapshot: price,
appliedTierId: tier ? tier.id : null,
// "Pick sessions" events: a per-session line.
sessionId: l.sessionId || null,
});
add('registrationOptionTranche', {
id: uuidv4(),
@@ -823,8 +844,11 @@ const IMPROVEMENTS = [
* donations { card, cash, amounts }, showRate, feedback (what people enjoyed), cashup.
*/
function buildPastEvent(people, spec) {
// Each profile has its own selection of past events (`always`: built by a profile's extras).
if (!spec.always && profile.past && !profile.past.includes(spec.title)) return null;
const { start } = spec;
const event = addEvent({
...(spec.seriesId ? { seriesId: spec.seriesId, seriesAnnouncedAt: start } : {}),
title: spec.title,
description: spec.description,
startDate: start,
@@ -1189,6 +1213,524 @@ async function closePastEvents() {
return results;
}
// ─── Profiles ────────────────────────────────────────────────────────────────
const UPCOMING = {
mensBreakfast: buildMensBreakfast,
familyFunDay: buildFamilyFunDay,
womensConference: buildWomensConference,
youthCamp: buildYouthCamp,
carols: buildCarols,
marriageWeekend: buildMarriageWeekend,
};
/**
* upcoming: which of the six upcoming events (UPCOMING); past: titles of past events to build
* (null = all ten); extras: the newer features (series, courses, conference, kids, volunteers);
* campuses: three campuses, switched on.
*/
const PROFILES = {
small: {
title: 'Small church — one congregation, the essentials',
about: [
'Three upcoming events: a breakfast (sold out, with a waitlist), a family fun day and free carols.',
'Four past events with check-ins and cashups (one left open to cash up live).',
'No campuses, kids check-in, volunteer rosters, courses or series.',
],
campuses: false,
upcoming: ['mensBreakfast', 'familyFunDay', 'carols'],
past: ['Church Family Picnic', 'Christmas Eve Candlelight Service', 'Easter Sunrise Service & Breakfast', 'Worship Night'],
extras: [],
},
events: {
title: 'Events & conferences — the full events demo (default)',
about: [
'Six upcoming events: breakfast with a waitlist, family day, women\'s conference (variants, early birds, coupons),',
'youth camp (parental consent, cabins, medical forms), free carols with donations, marriage weekend (bundles).',
'Ten past events with door sales, refunds, feedback and every kind of cashup; funnel analytics.',
'One campus; no kids check-in, rosters, courses or series.',
],
campuses: false,
upcoming: Object.keys(UPCOMING),
past: null,
extras: [],
},
ministry: {
title: 'Weekly ministry — one campus, courses, kids church and volunteers',
about: [
'Alpha Course running now (7 weekly sessions, attendance per session, late joining, certificates) and a finished',
'Marriage Course with certificates issued; a 3-day leadership conference sold by day ("pick sessions").',
'Kids Church every Sunday with kids check-in: families, children (some allergies), rooms with ratios, past check-ins.',
'Volunteer rosters for kids church and the family day (sign-ups, QR check-ins, an open cover request).',
'A Men\'s Breakfast series (two past, three coming) with followers and a season pass; family day, women\'s conference, carols.',
],
campuses: false,
upcoming: ['familyFunDay', 'womensConference', 'carols'],
past: ['Church Family Picnic', 'Christmas Eve Candlelight Service', 'Kids Holiday Club', 'Youth Winter Camp', 'Worship Night'],
extras: ['series', 'courses', 'conference', 'kids', 'volunteers'],
},
multicampus: {
title: 'Multi-campus church — three campuses',
about: [
'Campuses Central, North and South (switched on), events per campus and church-wide, home campuses for most people.',
'Staff limited to a campus: Anri Botha (North) and Daniel Pillay (South).',
'The Men\'s Breakfast series moves between the campuses, with followers and a season pass.',
'Kids church with kids check-in and volunteer rosters at Central and North.',
'All six upcoming and ten past events of the events demo, spread over the campuses.',
],
campuses: true,
upcoming: ['familyFunDay', 'womensConference', 'youthCamp', 'carols', 'marriageWeekend'],
past: null,
extras: ['series', 'kids', 'volunteers'],
},
};
let profile = PROFILES[PROFILE_NAME];
const hex = (bytes = 16) => crypto.randomBytes(bytes).toString('hex');
let campuses = [];
// ─── Campuses (multicampus) ──────────────────────────────────────────────────
function buildCampuses() {
const campus = (name, slug, address, colour, defaultLocation, order) => add('campus', {
id: uuidv4(), name, slug, address, contactName: 'Lerato Khumalo', contactEmail: `${slug}@example.com`,
colour, defaultLocation, order, createdAt: addTime(NOW, -300 * DAY), updatedAt: NOW,
});
campuses = [
campus('Central', 'central', '12 Church Street, Hatfield, Pretoria', '#2563eb', 'Main auditorium', 0),
campus('North', 'north', '45 Zambezi Drive, Montana', '#16a34a', 'North campus hall', 1),
campus('South', 'south', '8 Lenchen Avenue, Centurion', '#ea580c', 'South campus hall', 2),
];
for (const u of rows.user) if (u.role === 'user' && chance(0.85)) u.homeCampusId = pick(campuses).id;
}
/** Spreads the events over the campuses; the big ones stay church-wide. */
function assignCampuses() {
const byTitle = {
'Family Fun Day': 1, 'Carols by Candlelight': 2, 'Youth Winter Camp': null, 'Christmas Eve Candlelight Service': 0,
'Rooted Women’s Conference': null, 'Ignite Summer Camp': null, 'Marriage Enrichment Weekend': null,
};
let i = 0;
for (const event of rows.event) {
if (event.campusId !== undefined && event.campusId !== null) continue; // set by its builder
if (event.seriesId || event.kidsCheckIn) continue;
const idx = event.title in byTitle ? byTitle[event.title] : (i++ % campuses.length);
event.campusId = idx === null ? null : campuses[idx].id;
if (idx !== null && event.location === 'Church hall') event.location = campuses[idx].defaultLocation;
}
}
/** Staff limited to one campus (a many-to-many, so it's set after the rows are in). */
async function scopeStaff(tx) {
const byName = (first) => staffTeam.find(s => s.name.startsWith(first));
for (const [first, campusIdx] of [['Anri', 1], ['Daniel', 2]]) {
await tx.user.update({ where: { id: byName(first).id }, data: { scopedCampuses: { connect: [{ id: campuses[campusIdx].id }] } } });
}
}
// ─── Event series and a season pass ──────────────────────────────────────────
function buildBreakfast(people, { start, title, sold }) {
const event = addEvent({
title,
description: 'A full breakfast, a guest speaker and an honest conversation about faith, work and family. Bring a friend.',
startDate: start,
endDate: addTime(start, 2.5 * HOUR),
registrationDeadline: addTime(start, -2 * DAY),
price: 150,
picture: IMG('photo-1414235077428-338989a2e8c0'),
location: 'Church hall',
contactName: 'Sipho Dlamini',
});
const ticket = addOption(event, { name: 'Breakfast ticket', price: 150, stockLimit: 60 });
addCost(event, 'Breakfast catering', 'per_item', 85, ticket);
for (const user of shuffle(people.men).slice(0, sold)) {
addRegistration(event, user, [line(ticket, 1)], { createdAt: pastDate(20, 1), ...payPlan({ card: 0.7, eft: 0.1, none: 0.2 }) });
}
return { event, ticket };
}
/**
* The Men's Breakfast as a series: two past breakfasts (cashed up), this month's (sold out) and two
* more, 18 followers, and a season pass for the next two with six bought. On the multi-campus demo
* each breakfast is at a different campus.
*/
function buildSeries(people) {
const series = add('eventSeries', {
id: uuidv4(), name: "Men's Breakfast", slug: 'mens-breakfast',
description: 'Once a month on a Saturday morning: a full breakfast, a guest speaker and an honest conversation about faith, work and family. Follow the series to hear when the next one is out.',
picture: IMG('photo-1414235077428-338989a2e8c0'), createdAt: addTime(NOW, -200 * DAY), updatedAt: NOW,
});
const campusAt = (i) => (profile.campuses ? campuses[i % campuses.length] : null);
const pastSpec = (title, daysAgo, speaker) => ({
always: true, seriesId: series.id, title, description: `Breakfast with ${speaker}.`,
start: lastWeekday(daysAgo, 6, 7, 30), hours: 2.5, picture: 'photo-1414235077428-338989a2e8c0', location: 'Church hall', pool: 'men',
options: [{ name: 'Breakfast ticket', price: 150 }],
costs: o => [['Breakfast catering', 'per_item', 85, o[0]]],
online: int(34, 42), onlinePay: { card: 80, eft: 15, none: 5 }, door: int(3, 8), doorPay: { cash: 70, card: 30 },
lines: o => [line(o[0], chance(0.2) ? 2 : 1)],
showRate: 0.9,
cashup: { close: 'quick', daysAfter: 3, notes: 'Quick closed — card and cash banked.' },
});
const past = [
buildPastEvent(people, pastSpec("Men's Breakfast: Fathers & Sons", 63, 'Pastor Ernest Molefe')),
buildPastEvent(people, pastSpec("Men's Breakfast: Faith at Work", 35, 'Thabo Nkosi')),
];
const current = buildMensBreakfast(people);
const next = buildBreakfast(people, { start: nextWeekday(36, 6, 7, 30), title: "Men's Breakfast: Leading at Home", sold: 22 });
const later = buildBreakfast(people, { start: nextWeekday(64, 6, 7, 30), title: "Men's Breakfast: Money & Generosity", sold: 9 });
[...past, current, next.event, later.event].forEach((event, i) => {
event.seriesId = series.id;
// Followers were told when each came out (so the series job doesn't email demo people).
event.seriesAnnouncedAt = event.goLiveAt < NOW ? event.goLiveAt : NOW;
const campus = campusAt(i);
if (campus) { event.campusId = campus.id; event.location = campus.defaultLocation; }
});
for (const user of shuffle(people.men).slice(0, 18)) add('seriesFollow', { id: uuidv4(), seriesId: series.id, userId: user.id, createdAt: pastDate(150, 3) });
// Season pass: the next two breakfasts for R260 (R300 separately), R130 per event.
const pass = add('seasonPass', {
id: uuidv4(), seriesId: series.id, name: 'Next two breakfasts', description: 'Both of the next breakfasts for one price.',
price: 260, isActive: true, saleEndsAt: null, createdAt: addTime(NOW, -20 * DAY), updatedAt: NOW,
});
[next, later].forEach((b, order) => add('seasonPassItem', { id: uuidv4(), passId: pass.id, eventId: b.event.id, eventOptionId: b.ticket.id, variantId: null, order }));
for (const user of shuffle(people.men).slice(0, 6)) {
const at = pastDate(15, 1);
const purchase = add('seasonPassPurchase', { id: uuidv4(), passId: pass.id, userId: user.id, quantity: 1, total: 260, status: 'paid', checkoutId: null, createdAt: at, updatedAt: at });
[next, later].forEach((b, i) => {
const reg = add('registration', { id: uuidv4(), userId: user.id, eventId: b.event.id, status: 'paid', discountTotal: 0, bundleDiscount: 0, ticketDelivery: 'payer', analyticsSessionId: null, seasonPassPurchaseId: purchase.id, createdAt: at });
const ro = add('registrationOption', { id: uuidv4(), registrationId: reg.id, eventOptionId: b.ticket.id, variantId: null, quantity: 1, priceSnapshot: 130, appliedTierId: null, sessionId: null });
add('registrationOptionTranche', { id: uuidv4(), registrationOptionId: ro.id, quantity: 1, priceSnapshot: 130, appliedTierId: null, createdAt: at });
add('payment', {
id: uuidv4(), amount: 130, method: 'card', userId: user.id, recordedById: null, registrationId: reg.id, eventId: b.event.id,
status: 'completed', externalId: i === 0 ? `demo_${uuidv4().replace(/-/g, '')}` : null, seasonPassPurchaseId: purchase.id, createdAt: after(at, 2, 10),
});
add('ticket', { id: uuidv4(), qrCode: uuidv4(), registrationOptionId: ro.id, userId: user.id, eventId: b.event.id, quantity: 1, emailSent: true, createdAt: at });
});
}
return [current, next.event, later.event];
}
// ─── Courses and a pick-your-days conference (ministry) ──────────────────────
function addSessions(event, first, count, { everyDays = 7, minutes = 120, labels = [], capacity = null } = {}) {
return Array.from({ length: count }, (_, k) => {
const startsAt = addTime(first, k * everyDays * DAY);
return add('eventSession', {
id: uuidv4(), eventId: event.id, label: labels[k] || null, startsAt, endsAt: addTime(startsAt, minutes * MINUTE),
order: k, capacity, location: null, createdAt: event.createdAt, updatedAt: NOW,
});
});
}
/** Check-ins per session that has ended, for the event's tickets. */
function attendSessions(event, sessions, showRate) {
const attended = new Map();
for (const s of sessions.filter(x => x.endsAt < NOW)) {
for (const t of rows.ticket.filter(x => x.eventId === event.id)) {
if (!chance(showRate)) continue;
add('ticketUsage', { id: uuidv4(), ticketId: t.id, scannedById: pick(staffTeam).id, quantityRedeemed: t.quantity, sessionId: s.id, scannedAt: within(addTime(s.startsAt, -20 * MINUTE), addTime(s.startsAt, 15 * MINUTE)) });
attended.set(t.id, (attended.get(t.id) || 0) + 1);
}
}
return attended;
}
const CERTIFICATE = (min) => ({
template: 'classic', title: 'Certificate of Completion', signatureImage: '', sendOnIssue: false,
body: 'This certifies that {name} has completed {event}, attending {sessionsAttended} of {totalSessions} sessions.',
signatureName: 'Pastor Ernest Molefe', signatureRole: 'Senior Pastor', rule: { type: 'count', value: min },
});
function buildCourses(people) {
// Alpha: seven Wednesdays, three already held; late joining until week 4 at a pro-rata price.
const first = lastWeekday(15, 3, 18, 30);
const alpha = addEvent({
title: 'Alpha Course',
description: 'Seven Wednesday evenings exploring the big questions of life and faith — each with dinner, a short talk and honest conversation in small groups. Everyone is welcome; no question is off limits.',
startDate: first, endDate: addTime(first, (6 * 7) * DAY + 2 * HOUR), price: 250,
picture: IMG('photo-1529070538774-1843cb3265df'), location: 'Church hall',
scheduleType: 'sessions', attendanceMode: 'whole', lateJoinEnabled: true, latePricing: 'prorata', checkInWindow: 'whole_day',
certificatesEnabled: true, certificateConfig: CERTIFICATE(5),
goLiveAt: addTime(first, -35 * DAY), createdAt: addTime(first, -40 * DAY),
});
const alphaSessions = addSessions(alpha, first, 7, { labels: ['Is there more to life than this?', 'Who is Jesus?', 'Why did Jesus die?', 'How can I have faith?', 'Why and how do I pray?', 'How can I resist evil?', 'What about the church?'] });
alpha.lateJoinUntilSessionId = alphaSessions[3].id;
const alphaTicket = addOption(alpha, { name: 'Course (dinner included)', price: 250, stockLimit: 40 });
for (const user of shuffle(people.everyone).slice(0, 30)) {
addRegistration(alpha, user, [line(alphaTicket, 1)], { createdAt: daytimeBetween(addTime(first, -30 * DAY), addTime(first, -DAY)), ...payPlan({ card: 0.7, eft: 0.2, none: 0.1 }) });
}
attendSessions(alpha, alphaSessions, 0.85);
// Marriage Course: finished six weeks ago; certificates issued to couples who came to 5 of 6.
const mFirst = lastWeekday(80, 4, 19);
const marriage = addEvent({
title: 'The Marriage Course',
description: 'Six Thursday evenings for couples: a candle-lit dinner for two, a short talk and time to talk together — no group sharing.',
startDate: mFirst, endDate: addTime(mFirst, 35 * DAY + 2 * HOUR), price: 500,
picture: IMG('photo-1529634806980-85c3dd6d34ac'), location: 'Church hall',
scheduleType: 'sessions', attendanceMode: 'whole', checkInWindow: 'whole_day',
certificatesEnabled: true, certificateConfig: CERTIFICATE(5),
goLiveAt: addTime(mFirst, -40 * DAY), createdAt: addTime(mFirst, -45 * DAY),
});
const mSessions = addSessions(marriage, mFirst, 6, { labels: ['Building strong foundations', 'The art of communication', 'Resolving conflict', 'The power of forgiveness', 'Parents and in-laws', 'Love in action'] });
const couple = addOption(marriage, { name: 'Couple', price: 500, stockLimit: 16 });
for (const user of shuffle(people.everyone).slice(0, 14)) {
addRegistration(marriage, user, [line(couple, 1)], { createdAt: daytimeBetween(addTime(mFirst, -35 * DAY), addTime(mFirst, -2 * DAY)), payBy: mFirst, ...payPlan({ card: 0.75, eft: 0.25 }) });
}
const attended = attendSessions(marriage, mSessions, 0.88);
const issuedAt = addTime(mSessions[5].endsAt, DAY);
for (const t of rows.ticket.filter(x => x.eventId === marriage.id)) {
const n = attended.get(t.id) || 0;
if (n < 5) continue;
const owner = rows.user.find(u => u.id === t.userId);
add('certificate', { id: uuidv4(), eventId: marriage.id, ticketId: t.id, userId: owner.id, name: owner.name, code: hex(4).toUpperCase(), sessionsAttended: n, totalSessions: 6, manual: false, issuedAt, emailedAt: issuedAt });
}
return [alpha];
}
function buildLeadershipConference(people) {
const day1 = nextWeekday(40, 4, 8, 30);
const event = addEvent({
title: 'Lead Well Leadership Conference',
description: 'Three days for everyone who leads — small groups, ministries, teams at work. Come to all three, or pick the days you can make.',
startDate: day1, endDate: addTime(day1, 2 * DAY + 8 * HOUR), price: 650,
picture: IMG('photo-1540575467063-178a50c2df87'), location: 'Main auditorium',
scheduleType: 'sessions', attendanceMode: 'pick', checkInWindow: 'whole_day',
});
const days = addSessions(event, day1, 3, { everyDays: 1, minutes: 8 * 60, labels: ['Day 1 — Vision', 'Day 2 — Teams', 'Day 3 — Practice'], capacity: 150 });
const full = addOption(event, { name: 'Full conference pass', price: 650, sessionMode: 'all' });
addTier(full, { deadline: addTime(day1, -14 * DAY), price: 550, stockLimit: 40 });
const dayPass = addOption(event, { name: 'Day pass', price: 280, sessionMode: 'pick' });
const buyers = shuffle(people.everyone);
for (const user of buyers.splice(0, 34)) {
addRegistration(event, user, [line(full, chance(0.2) ? 2 : 1)], { createdAt: pastDate(30, 1), ...payPlan({ card: 0.8, eft: 0.12, none: 0.08 }) });
}
for (const user of buyers.splice(0, 26)) {
const picked = shuffle(days).slice(0, chance(0.4) ? 2 : 1);
addRegistration(event, user, picked.map(s => line(dayPass, 1, { sessionId: s.id })), { createdAt: pastDate(25, 1), ...payPlan({ card: 0.8, none: 0.2 }) });
}
return [event];
}
// ─── Kids church with kids check-in ──────────────────────────────────────────
const ROOMS = [['Nursery', 0, 2, 3, 12], ['Little Ones', 3, 5, 6, 24], ['Explorers', 6, 9, 8, 30], ['Pre-teens', 10, 12, 10, 24]];
const inFamily = new Set();
const kidsChurches = [];
function ageAt(dob, at) {
let age = at.getFullYear() - dob.getFullYear();
if (at.getMonth() < dob.getMonth() || (at.getMonth() === dob.getMonth() && at.getDate() < dob.getDate())) age--;
return Math.max(0, age);
}
/** A pickup code that's free on this occasion (4 characters, no look-alikes). */
function kidsCode(taken) {
const { CODE_ALPHABET, CODE_LENGTH } = require('../src/utils/kids');
for (;;) {
let c = '';
for (let i = 0; i < CODE_LENGTH; i++) c += CODE_ALPHABET[int(0, CODE_ALPHABET.length - 1)];
if (!taken.has(c)) { taken.add(c); return c; }
}
}
/**
* Sunday kids church: a hidden event with a session every Sunday (four held, eight to come), the
* kids ticket and rooms, 16 families with their children and authorised adults, and check-ins
* and check-outs (with pickup codes, tickets and room duty) for the Sundays already held.
*/
function buildKidsChurch(people, campus = null) {
const { encrypt } = require('../src/utils/encryption');
const first = lastWeekday(28, 0, 9, 0);
const event = addEvent({
title: campus ? `Kids Church — ${campus.name}` : 'Kids Church',
description: 'Sunday kids church during the 09:00 service, for babies to 12-year-olds. Check in at the kids desk: each family gets a pickup code, and only someone with the code can collect.',
startDate: first, endDate: addTime(first, 77 * DAY + 90 * MINUTE), price: 0, isHidden: true,
scheduleType: 'sessions', attendanceMode: 'whole', checkInWindow: 'whole_day', kidsCheckIn: true,
location: campus ? `${campus.name} kids wing` : 'Kids wing', campusId: campus ? campus.id : null,
goLiveAt: addTime(first, -30 * DAY), createdAt: addTime(first, -32 * DAY),
});
const sessions = addSessions(event, first, 12, { minutes: 90 });
// A session today, so the desk and room screens have children in right now whatever day the
// demo is shown (on a Sunday it's that Sunday's session).
let live = sessions.find(s => s.startsAt.toDateString() === NOW.toDateString() && s.endsAt > NOW);
if (!live) {
const startsAt = new Date(NOW); startsAt.setMinutes(0, 0, 0); startsAt.setHours(Math.max(8, startsAt.getHours() - 1));
live = add('eventSession', { id: uuidv4(), eventId: event.id, label: 'Midweek Kids Club', startsAt, endsAt: addTime(startsAt, 3 * HOUR), order: 0, capacity: null, location: null, createdAt: event.createdAt, updatedAt: NOW });
sessions.push(live);
sessions.sort((a, b) => a.startsAt - b.startsAt).forEach((s, order) => { s.order = order; });
}
const option = addOption(event, { name: 'Kids check-in', price: 0 });
event.kidsOptionId = option.id;
const set = add('allocationSet', { id: uuidv4(), eventId: event.id, name: 'Kids rooms', showOnTicket: false, order: 0 });
event.kidsRoomSetId = set.id;
const rooms = ROOMS.map(([name, minAge, maxAge, ratio, capacity], order) => add('allocationGroup', { id: uuidv4(), setId: set.id, name, minAge, maxAge, ratio, capacity, order, gender: null, leaderUserId: null }));
const roomFor = age => rooms.find(r => age >= r.minAge && age <= r.maxAge) || rooms[rooms.length - 1];
const parents = shuffle(people.everyone).filter(u => !inFamily.has(u.id)).slice(0, 16);
const families = parents.map(parent => {
inFamily.add(parent.id);
const { last } = userMeta.get(parent.id);
const createdAt = pastDate(200, 40);
const family = add('family', { id: uuidv4(), name: `The ${last} family`, qrToken: hex(), createdAt, updatedAt: NOW });
add('familyMember', { id: uuidv4(), familyId: family.id, userId: parent.id, createdAt });
if (chance(0.5)) {
add('authorisedAdult', { id: uuidv4(), familyId: family.id, name: `${pick(chance(0.5) ? FEMALE_FIRST : MALE_FIRST)} ${last}`, phone: null, relationship: pick(['Grandmother', 'Grandfather', 'Aunt', 'Uncle']), createdAt });
}
const children = Array.from({ length: int(1, 3) }, () => {
const girl = chance(0.5);
const dob = new Date(NOW);
dob.setFullYear(dob.getFullYear() - int(1, 12), int(0, 11), int(1, 28));
dob.setHours(0, 0, 0, 0);
return add('child', {
id: uuidv4(), familyId: family.id, firstName: pick(girl ? GIRL_FIRST : BOY_FIRST), lastName: last, dateOfBirth: dob, gender: girl ? 'female' : 'male',
allergies: chance(0.15) ? encrypt(pick(['Peanuts', 'Tree nuts', 'Dairy', 'Eggs', 'Bee stings'])) : null,
medicalNotes: chance(0.06) ? encrypt('Asthma — inhaler in the nappy bag') : null,
custodyNotes: chance(0.04) ? encrypt('Only the mother or grandmother may collect') : null,
lastCheckInAt: null, createdAt, updatedAt: NOW,
});
});
return { parent, family, children };
});
const held = sessions.filter(s => s.endsAt < NOW);
const codes = new Map([...held, live].map(s => [s.id, new Set()]));
let liveFamilies = 0;
for (const f of families) {
const attending = held.filter(() => chance(0.75));
// About half the families are in right now (checked in, not yet collected).
const inNow = live.startsAt <= NOW && liveFamilies < 8 && chance(0.6);
if (inNow) { attending.push(live); liveFamilies++; }
if (!attending.length) continue;
// The desk's first check-in gives the parent a free kids registration: a named place and ticket per child.
const { regLines } = addRegistration(event, f.parent, [line(option, f.children.length, {
attendees: f.children.map(c => ({ name: `${c.firstName} ${c.lastName}`, dateOfBirth: c.dateOfBirth, gender: c.gender, parentName: f.parent.name, parentEmail: f.parent.email })),
})], { createdAt: addTime(attending[0].startsAt, -25 * MINUTE) });
const attendees = regLines[0].attendees;
for (const s of attending) {
const code = add('kidsPickupCode', { id: uuidv4(), eventId: event.id, occasion: s.id, code: kidsCode(codes.get(s.id)), familyId: f.family.id, createdAt: addTime(s.startsAt, -int(10, 20) * MINUTE) });
const isLive = s === live;
f.children.forEach((c, i) => {
if (!chance(0.92)) return;
const at = isLive
? within(addTime(NOW, -50 * MINUTE), addTime(NOW, -5 * MINUTE))
: within(addTime(s.startsAt, -20 * MINUTE), addTime(s.startsAt, 10 * MINUTE));
const ticket = rows.ticket.find(t => t.attendeeId === attendees[i].id);
const desk = pick(staffTeam);
add('kidsCheckIn', {
id: uuidv4(), eventId: event.id, occasion: s.id, sessionId: s.id, childId: c.id, childName: `${c.firstName} ${c.lastName}`,
roomId: roomFor(ageAt(c.dateOfBirth, s.startsAt)).id, pickupCodeId: code.id, attendeeId: attendees[i].id, ticketId: ticket.id,
checkedInAt: at, checkedInById: desk.id,
checkedOutAt: isLive ? null : within(addTime(s.endsAt, -5 * MINUTE), addTime(s.endsAt, 15 * MINUTE)),
checkedOutById: isLive ? null : pick(staffTeam).id,
collectedBy: isLive ? null : chance(0.85) ? f.parent.name : 'Authorised adult', offline: false,
});
add('ticketUsage', { id: uuidv4(), ticketId: ticket.id, scannedById: desk.id, scannedAt: at, sessionId: s.id, quantityRedeemed: 1 });
if (!c.lastCheckInAt || at > c.lastCheckInAt) c.lastCheckInAt = at;
});
}
}
for (const s of [...held, live]) {
const isLive = s === live;
if (isLive && live.startsAt > NOW) continue;
for (const room of rooms) {
for (const leader of shuffle(staffTeam).slice(0, int(1, 2))) {
add('kidsRoomDuty', {
id: uuidv4(), roomId: room.id, occasion: s.id, userId: leader.id, fromRoster: false,
startedAt: isLive ? addTime(NOW, -55 * MINUTE) : addTime(s.startsAt, -15 * MINUTE),
endedAt: isLive ? null : addTime(s.endsAt, 10 * MINUTE),
});
}
}
}
kidsChurches.push({ event, sessions, rooms });
return [event];
}
// ─── Volunteer rosters ───────────────────────────────────────────────────────
/**
* Serving roles with people on them. `roles`: [name, needed, room?]. For a held session or event
* the places are filled and most checked in (QR); later ones are partly signed up. Reminders are
* marked as sent, so the reminder job doesn't email demo people.
*/
function addRoster(people, event, { session = null, roles, rooms = [] }) {
const start = session ? session.startsAt : event.startDate;
const held = (session ? session.endsAt : event.endDate) < NOW;
const daysAway = (start - NOW) / DAY;
const pool = shuffle(people.everyone);
const slots = [];
roles.forEach(([name, needed, roomName], order) => {
const role = add('servingRole', {
id: uuidv4(), eventId: event.id, sessionId: session ? session.id : null, name, description: null, needed,
startsAt: null, endsAt: null, kidsRoomId: roomName ? rooms.find(r => r.name === roomName)?.id || null : null,
order, createdAt: addTime(NOW, -25 * DAY), updatedAt: NOW,
});
const filled = held ? needed : daysAway < 10 ? int(Math.max(0, needed - 1), needed) : int(0, Math.max(0, needed - 1));
for (const user of pool.splice(0, filled)) {
const checked = held && chance(0.9);
// Most scan the QR code themselves; now and then a staff member ticks them in.
const method = checked ? (chance(0.85) ? 'qr' : 'manual') : null;
slots.push(add('volunteerSlot', {
id: uuidv4(), roleId: role.id, userId: user.id, assignedById: chance(0.3) ? staffTeam[0].id : null,
checkedInAt: checked ? within(addTime(start, -40 * MINUTE), addTime(start, -5 * MINUTE)) : null,
checkedInById: method === 'qr' ? user.id : method ? pick(staffTeam).id : null, checkInMethod: method,
remindedAt: held ? addTime(start, -DAY) : NOW, registrationId: null, createdAt: pastDate(20, 1),
}));
}
});
return slots;
}
function buildVolunteers(people, built) {
for (const { event, sessions, rooms } of kidsChurches) {
event.volunteerCheckInToken = hex();
const kidsRoles = [['Welcome desk', 3], ['Nursery leader', 2, 'Nursery'], ['Little Ones leader', 2, 'Little Ones'], ['Explorers leader', 2, 'Explorers'], ['Pre-teens leader', 1, 'Pre-teens']];
const lastHeld = sessions.filter(s => s.endsAt < NOW).pop();
const coming = sessions.filter(s => s.startsAt > NOW).slice(0, 2);
if (lastHeld) addRoster(people, event, { session: lastHeld, roles: kidsRoles, rooms });
coming.forEach((session, i) => {
const slots = addRoster(people, event, { session, roles: kidsRoles, rooms });
// Someone can't make the next Sunday and has asked for cover.
if (i === 0 && slots.length) {
const slot = slots[0];
add('volunteerSwap', { id: uuidv4(), slotId: slot.id, roleId: slot.roleId, fromUserId: slot.userId, toUserId: null, note: 'Away for a family wedding — can anyone take this?', status: 'open', createdAt: pastDate(2) });
}
});
}
const dayEvent = built.find(e => e.title === 'Family Fun Day');
if (dayEvent) {
dayEvent.volunteerCheckInToken = hex();
addRoster(people, dayEvent, { roles: [['Parking marshals', 4], ['Welcome & info table', 3], ['First aid', 1], ['Coffee bar', 3], ['Jumping castle supervisors', 2]] });
}
}
/** The extras of the chosen profile, in order; returns the upcoming events they add. */
function buildExtras(people, built) {
const out = [];
const has = (x) => profile.extras.includes(x);
if (has('series')) out.push(...buildSeries(people));
if (has('courses')) out.push(...buildCourses(people));
if (has('conference')) out.push(...buildLeadershipConference(people));
if (has('kids')) {
if (profile.campuses) for (const c of campuses.slice(0, 2)) out.push(...buildKidsChurch(people, c));
else out.push(...buildKidsChurch(people));
}
if (has('volunteers')) buildVolunteers(people, built.concat(out));
return out;
}
function listProfiles() {
console.log('\nDemo profiles — npm run demo:<name> (from the repo root):\n');
for (const [name, p] of Object.entries(PROFILES)) {
console.log(` ${name.padEnd(12)} ${p.title}`);
for (const line of p.about) console.log(` ${''.padEnd(12)} ${line}`);
console.log('');
}
}
// ─── Funnel analytics ────────────────────────────────────────────────────────
const SOURCES = { direct: 30, whatsapp: 35, facebook: 18, instagram: 10, email: 7 };
@@ -1198,7 +1740,8 @@ function buildAnalytics() {
const { sessionsFrom, rollupRows, RAW_DAYS } = require('../src/utils/analytics');
const rawCutoff = addTime(NOW, -RAW_DAYS * DAY);
for (const event of rows.event) {
// Hidden events (kids church) aren't shown publicly, so nobody "visits" them.
for (const event of rows.event.filter(e => !e.isHidden)) {
const raw = [];
const session = (stages, at) => {
const sessionId = uuidv4();
@@ -1242,10 +1785,25 @@ function buildAnalytics() {
async function clearData(tx) {
const tables = await tx.$queryRaw`SELECT tablename FROM pg_tables WHERE schemaname = current_schema()`;
const toClear = tables.map(t => t.tablename).filter(t => !KEEP_TABLES.has(t));
// Tables the User table points at (e.g. Campus via homeCampusId): TRUNCATE ... CASCADE on them
// would truncate User too — and with it the admin we keep — so their rows are deleted instead,
// after the admin's links to them are cleared.
const userRefs = await tx.$queryRaw`
SELECT DISTINCT ref.relname AS table, att.attname AS column
FROM pg_constraint c
JOIN pg_class src ON src.oid = c.conrelid
JOIN pg_class ref ON ref.oid = c.confrelid
JOIN pg_attribute att ON att.attrelid = c.conrelid AND att.attnum = ANY (c.conkey)
WHERE c.contype = 'f' AND src.relname = 'User' AND ref.relname <> 'User'`;
const deleteInstead = new Set(userRefs.map(r => r.table));
const toClear = tables.map(t => t.tablename).filter(t => !KEEP_TABLES.has(t) && !deleteInstead.has(t));
if (toClear.length) {
await tx.$executeRawUnsafe(`TRUNCATE TABLE ${toClear.map(t => `"${t}"`).join(', ')} CASCADE`);
}
if (userRefs.length) {
await tx.$executeRawUnsafe(`UPDATE "User" SET ${[...new Set(userRefs.map(r => `"${r.column}" = NULL`))].join(', ')}`);
for (const table of deleteInstead) await tx.$executeRawUnsafe(`DELETE FROM "${table}"`);
}
// Their password resets, 2FA codes and trusted devices go with them (onDelete: Cascade).
const { count } = await tx.user.deleteMany({ where: { id: { not: admin.id } } });
await tx.securityEvent.deleteMany({ where: { OR: [{ userId: null }, { userId: { not: admin.id } }] } });
@@ -1269,6 +1827,7 @@ function describeDatabase() {
}
async function confirm(admin) {
console.log(`\nDemo profile "${PROFILE_NAME}": ${profile.title}`);
if (SKIP_PROMPT) return true;
console.log(`\nThis will DELETE all events, registrations, payments, tickets, coupons and users`);
console.log(`in ${describeDatabase()}, keeping only the site settings and ${admin.email}.`);
@@ -1279,6 +1838,13 @@ async function confirm(admin) {
}
async function main() {
if (LIST_ONLY) { listProfiles(); return; }
if (!profile) {
console.error(`Unknown demo profile "${PROFILE_NAME}". Choose one of: ${Object.keys(PROFILES).join(', ')}.`);
listProfiles();
process.exitCode = 1;
return;
}
admin = await prisma.user.findFirst({ where: { email: { equals: ADMIN_EMAIL, mode: 'insensitive' } } });
if (!admin) {
console.error(`Admin account ${ADMIN_EMAIL} was not found — nothing was changed.`);
@@ -1293,17 +1859,13 @@ async function main() {
return;
}
passwordHash = await bcrypt.hash(DEMO_PASSWORD, 10);
passwordHash = await hashPassword(DEMO_PASSWORD);
const people = buildPeople();
const events = [
buildMensBreakfast(people),
buildFamilyFunDay(people),
buildWomensConference(people),
buildYouthCamp(people),
buildCarols(people),
buildMarriageWeekend(people),
];
if (profile.campuses) buildCampuses();
const events = profile.upcoming.map(key => UPCOMING[key](people));
events.push(...buildExtras(people, events));
buildPastEvents(people);
if (profile.campuses) assignCampuses();
buildAnalytics();
const cleared = await prisma.$transaction(async (tx) => {
@@ -1311,6 +1873,13 @@ async function main() {
for (const model of INSERT_ORDER) {
if (rows[model] && rows[model].length) await tx[model].createMany({ data: rows[model] });
}
if (profile.campuses) await scopeStaff(tx);
// Campuses on for the multi-campus demo, off for the others (site settings are kept).
await tx.appSetting.upsert({
where: { key: 'campuses_enabled' },
update: { value: profile.campuses ? 'true' : 'false' },
create: { key: 'campuses_enabled', value: profile.campuses ? 'true' : 'false' },
});
return result;
}, { maxWait: 10 * 1000, timeout: 5 * 60 * 1000 });
clearScheduledEmails();
@@ -1348,7 +1917,17 @@ async function main() {
].filter(Boolean).join(', ');
console.log(` ${event.startDate.toDateString().padEnd(16)} ${event.title.padEnd(34)} ${String(regs).padStart(3)} registrations, ${String(scans).padStart(3)} check-ins — ${CASHUP_LABELS[cashup.close]}${notes ? ` (${notes})` : ''}`);
}
console.log(`\nCreated ${count('user')} users, ${count('payment')} payments, ${count('ticket')} tickets, ${count('ticketUsage')} check-ins, ${count('attendee')} named campers, ${count('feedbackResponse')} feedback responses, ${count('analyticsEvent') + count('analyticsDaily')} funnel rows.`);
console.log(`\nCreated ${count('user')} users, ${count('payment')} payments, ${count('ticket')} tickets, ${count('ticketUsage')} check-ins, ${count('attendee')} named attendees, ${count('feedbackResponse')} feedback responses, ${count('analyticsEvent') + count('analyticsDaily')} funnel rows.`);
const extras = [
count('campus') && `${count('campus')} campuses (on), 2 campus-limited staff`,
count('eventSeries') && `${count('eventSeries')} series with ${count('seriesFollow')} followers, ${count('seasonPassPurchase')} season passes sold`,
count('eventSession') && `${count('eventSession')} sessions`,
count('certificate') && `${count('certificate')} certificates`,
count('family') && `${count('family')} families, ${count('child')} children, ${count('kidsCheckIn')} kids check-ins`,
count('servingRole') && `${count('servingRole')} serving roles, ${count('volunteerSlot')} volunteers rostered, ${count('volunteerSwap')} cover request`,
].filter(Boolean);
if (extras.length) console.log(`Also: ${extras.join('; ')}.`);
console.log(`Campuses are ${profile.campuses ? 'ON' : 'off'} — restart the backend so it picks the setting up.`);
console.log(`\nKept: site settings and ${admin.email}.`);
console.log(`Demo staff logins (password ${DEMO_PASSWORD}):`);
for (const s of staffTeam) console.log(` ${s.role.padEnd(10)} ${s.email}`);
+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 };
+466
View File
@@ -0,0 +1,466 @@
const path = require('path');
const dotenv = require('dotenv');
// Load environment variables
dotenv.config();
// Error monitoring — a no-op if SENTRY_DSN isn't set, so this is safe in every
// environment (dev, a fresh deploy that hasn't configured Sentry yet, etc.).
// Must run before express/@prisma/client are required below — Sentry's
// auto-instrumentation patches those modules via a require hook, which only
// works if Sentry.init() runs before they're first required into the cache.
if (process.env.SENTRY_DSN) {
const Sentry = require('@sentry/node');
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV || 'development',
// Small single-VM deployment, not high-traffic — start conservative and raise
// this once real usage is visible in Sentry, rather than sampling every request.
tracesSampleRate: 0.1,
});
}
const express = require('express');
const { version: API_VERSION } = require('../package.json');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const prisma = require('./config/db');
const { notFound, errorHandler } = require('./middleware/errorMiddleware');
const getRawBody = require('raw-body');
// Initialize Express app
const app = express();
const PORT = process.env.PORT || 3000;
// Express 5 parses query strings the simple way by default; keep Express 4's (qs) so
// ?a[b]=1 style filters keep working.
app.set('query parser', 'extended');
// Trust proxy — required when running behind a reverse proxy (e.g. nginx on a
// separate server) so req.ip / X-Forwarded-For are read correctly by
// express-rate-limit and friends. Accepts a hop count ("1"), "true"/"false",
// or a comma-separated list of trusted proxy IPs/CIDRs.
if (process.env.TRUST_PROXY) {
const raw = process.env.TRUST_PROXY.trim();
let trustProxyValue;
if (raw === 'true') trustProxyValue = true;
else if (raw === 'false') trustProxyValue = false;
else if (/^\d+$/.test(raw)) trustProxyValue = parseInt(raw, 10);
else if (raw.includes(',')) trustProxyValue = raw.split(',').map((s) => s.trim());
else trustProxyValue = raw;
app.set('trust proxy', trustProxyValue);
console.log(`[startup] trust proxy set to: ${JSON.stringify(trustProxyValue)}`);
}
// CORS — allow only the configured frontend origin
const allowedOrigins = (process.env.FRONTEND_URL || 'http://localhost:3000')
.split(',')
.map((o) => o.trim());
const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV;
const isTesting = process.env.NODE_ENV === 'testing';
const isProd = process.env.NODE_ENV === 'production';
const modeLabel = isProd ? 'production' : isTesting ? 'testing' : 'development';
console.log(`Running in ${modeLabel} mode.`);
// In testing or production, require all critical env vars to be set at startup
if (isTesting || isProd) {
const required = ['DATABASE_URL', 'JWT_SECRET'];
const missing = required.filter(k => !process.env[k]);
if (missing.length > 0) {
console.error(`[startup] Missing required environment variables for ${modeLabel} mode: ${missing.join(', ')}`);
process.exit(1);
}
}
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (e.g. mobile apps, curl, Postman)
if (!origin) return callback(null, true);
// Always allow configured origins
if (allowedOrigins.includes(origin)) return callback(null, true);
// In dev or testing, also allow any LAN origin on the same port (e.g. phone on 192.168.x.x:3000)
if (isDev || isTesting) {
try {
const u = new URL(origin);
const isLan = /^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(u.hostname);
if (isLan) return callback(null, true);
} catch {}
}
callback(new Error(`CORS: origin '${origin}' not allowed`));
},
credentials: true,
}));
// Global rate limiter — 5000 requests per 5 minutes per IP
// Webhooks are excluded because they come from Yoco's servers and are already HMAC-verified
const globalLimiter = rateLimit({
windowMs: 5 * 60 * 1000,
max: 5000,
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many requests, please try again later.' },
skip: (req) => RAW_BODY_PREFIXES.some(p => req.path.startsWith(p)),
});
// Incoming webhooks and links in sent messages on v2 (1.51.16): see src/v2/links.ts.
const { RAW_BODY_PREFIXES, webhooksRouter, linksRouter, legacyLinkRedirects, legacyV1Router, v1Removed } = require('./v2/links');
// /api/v2 answers every error as { error: { code, message } }, including the shared
// middleware below (rate limits, read-only guards, auth) that writes v1's { message }.
app.use('/api/v2', require('./v2/errors').legacyErrorsToV2);
app.use(globalLimiter);
// Impersonation is strictly read-only: refuse every write made with an impersonation
// token before it can reach any route (see utils/impersonation.js).
app.use(require('./utils/impersonation').readOnlyGuard);
// Raw body parsing middleware for webhooks
app.use((req, res, next) => {
if (RAW_BODY_PREFIXES.some(p => req.path.startsWith(p))) {
getRawBody(req, {
length: req.headers['content-length'],
encoding: 'utf-8'
}, (err, rawBody) => {
if (err) return next(err);
req.rawBody = rawBody;
next();
});
} else {
next();
}
});
const webhookRoutes = require('./routes/webhookRoutes');
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
app.use('/api/webhooks', webhookRoutes);
app.use('/api/v2/webhooks', webhooksRouter);
// Apply JSON body parser for all non-webhook routes
app.use(express.json());
// Express 5 leaves req.body undefined when a request has no JSON body; handlers expect {}.
app.use((req, res, next) => {
if (req.body === undefined) req.body = {};
next();
});
// Count server errors (any 5xx response) for the Hub heartbeat.
app.use((req, res, next) => {
res.on('finish', () => { if (res.statusCode >= 500) require('./utils/errorCounter').recordError(); });
next();
});
// Licence suspended, revoked or missing: no new or edited events, registrations or payments (src/hub/readOnly.js).
app.use(require('./hub/readOnly').licenceReadOnly);
// Health for monitoring (and the Hub): no secrets, no personal information.
app.get('/health', async (req, res) => {
const report = require('./hub/report');
let db = true;
try { await prisma.$queryRaw`SELECT 1`; } catch { db = false; }
const health = db ? await report.health().catch(() => null) : null;
res.status(db ? 200 : 503).json({
status: db ? 'ok' : 'error',
version: report.APP_VERSION,
db,
jobs: health?.jobs || null,
lastBackupAt: health?.lastBackupAt || null,
});
});
// Use routes
// /api/v2 (1.51.0, TypeScript): consistent errors and paging, described by OpenAPI at /docs/v2.
app.use('/api/v2', linksRouter);
app.use('/api/v2', require('./v2').v2Router());
// Links in messages already sent: v1 addresses answer 308 with their v2 one.
app.use(legacyLinkRedirects);
app.use('/docs/v2', require('./v2').v2DocsRouter());
// v1 (/api/…) was removed in 2.0. Still answered: provider webhooks (above, and WAWP's old
// address) and links in messages already sent (308s above, a mail app's one-click POST here).
app.use('/api', legacyV1Router);
app.use('/api', v1Removed);
// Pre-warm the settings cache so synchronous helpers have DB values from startup
const { getSettingSync, warmCache } = require('./utils/settingsCache');
warmCache().catch(() => {});
// Uploaded branding assets can include SVGs, which may embed <script>/event
// handlers. Serving them inline lets a compromised/malicious upload run script
// in the site's origin if opened directly, so pin the safe response headers
// (no inline execution, no MIME-sniffing to HTML/script) on every asset here.
app.use('/uploads', express.static('public/uploads', {
setHeaders: (res) => {
res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox");
res.setHeader('X-Content-Type-Options', 'nosniff');
},
}));
// ── Shared page helpers ────────────────────────────────────────────────────────
function pageShell(title, accentColor, bodyHtml) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f3f4f6;color:#1f2937;min-height:100vh;padding:24px 16px}
.wrap{max-width:960px;margin:0 auto}
h1{font-size:1.5rem;font-weight:700;color:#111827;margin-bottom:4px}
h2{font-size:1.1rem;font-weight:600;color:#111827;margin:24px 0 10px}
.subtitle{font-size:.85rem;color:#6b7280;margin-bottom:24px}
.card{background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:20px;margin-bottom:16px}
.stat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:16px}
.stat{background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:14px 16px}
.stat-label{font-size:.72rem;text-transform:uppercase;letter-spacing:.05em;color:#6b7280;margin-bottom:4px}
.stat-value{font-size:1.1rem;font-weight:600;color:#111827}
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:.75rem;font-weight:600}
.badge-ok{background:#d1fae5;color:#065f46}
.badge-warn{background:#fef3c7;color:#92400e}
.badge-err{background:#fee2e2;color:#991b1b}
table{width:100%;border-collapse:collapse;font-size:.82rem}
th{text-align:left;padding:6px 10px;background:#f9fafb;border-bottom:1px solid #e5e7eb;font-weight:600;color:#374151}
td{padding:6px 10px;border-bottom:1px solid #f3f4f6;color:#374151;vertical-align:top}
tr:last-child td{border-bottom:none}
.method{display:inline-block;padding:1px 7px;border-radius:4px;font-size:.7rem;font-weight:700;font-family:monospace}
.get{background:#dbeafe;color:#1e40af}
.post{background:#d1fae5;color:#065f46}
.put{background:#fef3c7;color:#92400e}
.patch{background:#ede9fe;color:#5b21b6}
.del{background:#fee2e2;color:#991b1b}
.auth-badge{font-size:.68rem;padding:1px 6px;border-radius:4px;background:#f3f4f6;color:#374151;font-family:monospace}
code{background:#f3f4f6;padding:1px 5px;border-radius:4px;font-family:monospace;font-size:.82rem}
.note{background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;padding:12px 16px;font-size:.82rem;color:#1e40af;margin-bottom:16px}
.accent{color:${accentColor}}
a{color:${accentColor};text-decoration:none}
a:hover{text-decoration:underline}
</style>
</head>
<body>
<div class="wrap">
${bodyHtml}
</div>
</body>
</html>`;
}
function formatUptime(seconds) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m ${s}s`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
function formatBytes(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(1)} MB`;
}
// ── Favicon ──────────────────────────────────────────────────────────────────
app.get('/favicon.ico', (req, res) => {
// From src/ either way (the build in dist/ copies only code).
res.sendFile(path.join(__dirname, '..', 'src', 'favicon.ico'));
});
// ── Root route — status page ────────────────────────────────────────────────
app.get('/', async (req, res) => {
const mem = process.memoryUsage();
const uptimeSec = process.uptime();
const now = new Date().toISOString();
// Database health check
let dbStatus = 'ok';
let dbMsg = 'Connected';
try {
await prisma.$queryRaw`SELECT 1`;
} catch (e) {
dbStatus = 'error';
dbMsg = 'Unreachable';
}
const dbBadge = dbStatus === 'ok'
? `<span class="badge badge-ok">&#10003; ${dbMsg}</span>`
: `<span class="badge badge-err">&#10007; ${dbMsg}</span>`;
const envBadge = isProd
? `<span class="badge badge-ok">production</span>`
: isTesting
? `<span class="badge badge-warn">testing</span>`
: `<span class="badge badge-warn">development</span>`;
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
const html = pageShell(`${orgName} Events API — Status`, '#2563eb', `
<h1>${orgName} Events API</h1>
<p class="subtitle">v${API_VERSION} &mdash; ${now}</p>
<div class="stat-grid">
<div class="stat">
<div class="stat-label">Status</div>
<div class="stat-value"><span class="badge badge-ok">&#10003; Online</span></div>
</div>
<div class="stat">
<div class="stat-label">Environment</div>
<div class="stat-value">${envBadge}</div>
</div>
<div class="stat">
<div class="stat-label">Database</div>
<div class="stat-value">${dbBadge}</div>
</div>
<div class="stat">
<div class="stat-label">Uptime</div>
<div class="stat-value">${formatUptime(uptimeSec)}</div>
</div>
<div class="stat">
<div class="stat-label">Heap Used</div>
<div class="stat-value">${formatBytes(mem.heapUsed)}</div>
</div>
<div class="stat">
<div class="stat-label">Heap Total</div>
<div class="stat-value">${formatBytes(mem.heapTotal)}</div>
</div>
<div class="stat">
<div class="stat-label">RSS</div>
<div class="stat-value">${formatBytes(mem.rss)}</div>
</div>
<div class="stat">
<div class="stat-label">Node</div>
<div class="stat-value">${process.version}</div>
</div>
</div>
<div class="note">
API documentation (OpenAPI) is at <a href="/docs/v2">/docs/v2</a> — it needs an admin account token.
</div>
`);
res.send(html);
});
// /docs: the v1 reference went with v1 in 2.0; the API is documented at /docs/v2 (OpenAPI).
app.get('/docs', (req, res) => res.redirect(301, '/docs/v2'));
// Error middleware
app.use(notFound);
// Sentry captures the error here, then passes it through unchanged — errorHandler
// below remains the sole source of what's actually sent back to the client.
if (process.env.SENTRY_DSN) {
const Sentry = require('@sentry/node');
Sentry.setupExpressErrorHandler(app);
}
app.use(errorHandler);
// Start server
app.listen(PORT, () => {
console.log(`Server is running at http://localhost:${PORT}`);
// Attempt a one-time background sync of attachment manifests into DB
try {
const { syncManifestsToDb } = require('./utils/attachmentsSync');
const prismaShared = require('./config/db');
setTimeout(async () => {
try {
const summary = await syncManifestsToDb(prismaShared, { dryRun: false });
if ((summary.imported || 0) > 0) {
console.log(`[attachments sync] Imported ${summary.imported} attachments from manifests (skipped ${summary.skipped}).`);
} else {
console.log('[attachments sync] No attachments imported (either none found or already in DB).');
}
} catch (e) {
console.warn('[attachments sync] Failed:', e?.message || e);
}
}, 1000);
} catch (e) {
console.warn('[attachments sync] Not scheduled:', e?.message || e);
}
// Timed background jobs (daily summary, waitlist, feedback, sessions, consent, analytics,
// backup, temp cleanup) run on the pg-boss job queue — see src/jobs/index.js.
require('./jobs').startJobs().catch(e => console.warn('[jobs] Not started:', e?.message || e));
// Check in with the Hub 30 s after starting, so the licence is fresh without waiting 15 minutes.
require('./hub/client').heartbeatSoon();
// Scheduled emails worker (polling)
try {
const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { getDueJobs, updateJob, purgeSentJobs } = require('./utils/scheduledEmails');
const { emailEventAttendees, whatsappEventAttendees } = require('./controllers/eventController');
const { sendBroadcast } = require('./controllers/broadcastController');
const { sendWhatsAppBroadcast } = require('./controllers/whatsappBroadcastController');
const intervalMs = parseInt(process.env.SCHEDULED_EMAILS_INTERVAL_MS || '30000', 10);
setInterval(async () => {
try {
try { purgeSentJobs(24 * 60 * 60 * 1000); } catch (e) { console.warn('[scheduled emails] purge failed:', e?.message || e); }
const due = getDueJobs(new Date());
if (!due || due.length === 0) return;
for (const job of due) {
try {
updateJob(job.id, { status: 'sending', attempts: (job.attempts || 0) + 1, lastError: null });
let responseBody = null; let code = 200;
const mockRes = {
status: (c) => { code = c; return mockRes; },
json: (b) => { responseBody = b; return mockRes; }
};
if (job.broadcast && job.channel === 'whatsapp') {
const mockReq = { body: job.payload };
await sendWhatsAppBroadcast(mockReq, mockRes);
} else if (job.broadcast) {
const mockReq = { body: job.payload };
await sendBroadcast(mockReq, mockRes);
} else if (job.channel === 'whatsapp') {
const mockReq = { params: { id: job.eventId }, body: job.payload };
await whatsappEventAttendees(mockReq, mockRes);
} else {
const mockReq = { params: { id: job.eventId }, body: job.payload };
await emailEventAttendees(mockReq, mockRes);
}
if (code >= 200 && code < 300) {
updateJob(job.id, { status: 'sent', sentAt: new Date().toISOString(), lastResult: responseBody || null });
} else {
updateJob(job.id, { status: 'error', lastError: (responseBody && responseBody.message) ? responseBody.message : `HTTP ${code}` });
}
} catch (e) {
updateJob(job.id, { status: 'error', lastError: String(e?.message || e) });
}
}
} catch (e) {
console.warn('[scheduled emails] poll failed:', e?.message || e);
}
}, isNaN(intervalMs) ? 30000 : intervalMs);
console.log('[scheduled emails] Worker initialized. Set SCHEDULED_EMAILS_ENABLED=false to disable.');
} else {
console.log('[scheduled emails] Worker disabled by env SCHEDULED_EMAILS_ENABLED=false');
}
} catch (e) {
console.warn('[scheduled emails] Worker not started:', e?.message || e);
}
});
// Log unhandled promise rejections without killing the server, since a single
// missed .catch() on fire-and-forget notification code (email/WhatsApp sends)
// would otherwise take the whole app down.
// On restart/stop (PM2 sends SIGINT), let a running background job finish first.
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, async () => {
try { await require('./jobs').stopJobs(); } catch { /* exiting anyway */ }
process.exit(0);
});
}
process.on('unhandledRejection', (err) => {
console.error('UNHANDLED REJECTION!', err?.name, err?.message);
console.error(err?.stack || err);
if (process.env.SENTRY_DSN) {
try { require('@sentry/node').captureException(err); } catch {}
}
});
module.exports = { app, prisma };
+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,
};
+81 -1
View File
@@ -3,7 +3,7 @@ const { PrismaPg } = require('@prisma/adapter-pg');
// Prisma 7 talks to Postgres through the node-postgres driver adapter instead of
// the old Rust engine. pg has no connect timeout by default; 5s matches Prisma 5.
function createPrismaClient() {
function createBaseClient() {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
connectionTimeoutMillis: 5000,
@@ -11,6 +11,86 @@ function createPrismaClient() {
return new PrismaClient({ adapter });
}
/**
* Outbound webhooks (utils/webhooks.js) notice what happens here, whichever code path does it:
* registrations made, paid or cancelled, payments and refunds, check-ins, new accounts, events
* becoming public. Noticing only queues; the record is read again before anything is sent, so a
* transaction that rolls back sends nothing. A failure here never affects the write itself.
*/
function withWebhooks(base) {
const notice = (type, id) => {
try { require('../utils/webhooks').notice(type, id); } catch (e) { console.warn('[webhooks] notice:', e?.message || e); }
};
const isPublic = e => !!e && e.isActive !== false && !e.isHidden;
// The status before an update (read outside the caller's transaction: the committed value).
const before = (model, where, select) => (where?.id ? base[model].findUnique({ where: { id: where.id }, select }).catch(() => null) : Promise.resolve(null));
return base.$extends({
query: {
registration: {
async create({ args, query }) {
const r = await query(args);
if (r?.id) {
notice('registration.created', r.id);
if (r.status === 'paid') notice('registration.paid', r.id);
}
return r;
},
async update({ args, query }) {
const status = args?.data?.status;
if (status !== 'paid' && status !== 'cancelled') return query(args);
const prev = await before('registration', args.where, { status: true });
const r = await query(args);
if (r?.id && prev && prev.status !== status) notice(status === 'paid' ? 'registration.paid' : 'registration.cancelled', r.id);
return r;
},
},
payment: {
async create({ args, query }) {
const p = await query(args);
// Applying an earlier donation to an order isn't new money (see cashupUtils.isDonationLeg).
const leg = p && !p.isDonation && p.originalPaymentId && p.amount > 0;
if (p?.id && !leg && p.amount !== 0) notice(p.amount < 0 ? 'payment.refunded' : 'payment.received', p.id);
return p;
},
},
ticketUsage: {
async create({ args, query }) {
const u = await query(args);
if (u?.id) notice('ticket.checked_in', u.id);
return u;
},
},
user: {
async create({ args, query }) {
const u = await query(args);
if (u?.id) notice('user.created', u.id);
return u;
},
},
event: {
async create({ args, query }) {
const e = await query(args);
if (e?.id && isPublic(e)) notice('event.published', e.id);
return e;
},
async update({ args, query }) {
const touches = args?.data && ('isActive' in args.data || 'isHidden' in args.data);
if (!touches) return query(args);
const prev = await before('event', args.where, { isActive: true, isHidden: true });
const e = await query(args);
if (e?.id && prev && !isPublic(prev) && isPublic(e)) notice('event.published', e.id);
return e;
},
},
},
});
}
function createPrismaClient() {
return withWebhooks(createBaseClient());
}
const prisma = createPrismaClient();
module.exports = prisma;
@@ -6,8 +6,8 @@ const { safeErrorMessage } = require('../utils/errorUtils');
// @access Admin
const listAuditLog = async (req, res) => {
try {
const { page, limit, actorId, action, from, to } = req.query;
const result = await getAdminAuditLog({ page, limit, actorId, action, from, to });
const { page, limit, actorId, action, role, from, to, search } = req.query;
const result = await getAdminAuditLog({ page, limit, actorId, action, role, from, to, search });
res.json(result);
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
@@ -1,4 +1,5 @@
const ExcelJS = require('exceljs');
// ExcelTS: a maintained TypeScript port of exceljs (same API, no dependencies).
const ExcelJS = require('@cj-tech-master/excelts');
const PDFDocument = require('pdfkit');
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
+17 -319
View File
@@ -1,325 +1,23 @@
const prisma = require('../config/db');
// Bundle deals (v1). The logic is in services/deals.ts, shared with /api/v2 (1.51.9).
const deals = require('../services/deals');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { KINDS, describeBundle, describeBundleItems, isBundleAvailable } = require('../utils/bundles');
const { previewBundles } = require('../utils/coupons');
const { basketBuyer, priceBasket } = require('./couponController');
const { scopeFor } = require('../utils/campusScope');
const round2 = (n) => Math.round(Number(n || 0) * 100) / 100;
const fmtR = (n) => `R${Number(n || 0).toFixed(2).replace(/\.00$/, '')}`;
const GROUP_PRICING = ['per_ticket', 'percent'];
const ctxOf = async (req) => ({ scope: await scopeFor(req), ip: getClientIp(req) });
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: safeErrorMessage(error), ...(error.code && !/^P\d/.test(error.code) ? { code: error.code } : {}) });
const optionalDate = (v, label) => {
if (v === undefined) return undefined;
if (v === null || v === '') return null;
const d = new Date(v);
if (isNaN(d.getTime())) throw new Error(`${label} is not a valid date`);
return d;
};
const actorOf = (req) => (req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null);
/** Standard (variant or option) price of one unit of an item. */
function itemPrice(i) {
const variant = i.variantId ? (i.eventOption?.variants || []).find(v => v.id === i.variantId) : null;
return Number((variant && variant.price !== null && variant.price !== undefined ? variant.price : i.eventOption?.price) || 0);
}
// @route GET /api/bundles/event/:eventId — with how often each has been used
const listBundles = async (req, res) => { try { res.json(await deals.listBundles(req.params.eventId)); } catch (e) { fail(res, e); } };
// @route POST /api/bundles/event/:eventId
const createBundle = async (req, res) => { try { res.status(201).json(await deals.createBundle(req.params.eventId, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route PUT /api/bundles/:id
const updateBundle = async (req, res) => { try { res.json(await deals.updateBundle(req.params.id, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route DELETE /api/bundles/:id (a used one is switched off instead)
const deleteBundle = async (req, res) => { try { res.json(await deals.deleteBundle(req.params.id, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route POST /api/bundles/quote — the deals a basket gets, before any coupon
const quoteBundles = async (req, res) => { try { res.json(await deals.quoteBundles(req.body || {}, actorOf(req))); } catch (e) { fail(res, e); } };
/**
* What the deal is worth at standard prices: `standardValue` is what an exact set costs
* separately (or the dearest pick-any set); `savingText` is a short "save …" line, or
* null when the headline already says it.
*/
function valueOf(bundle) {
const kind = bundle.kind || 'exact';
const items = bundle.items || [];
const dearest = items.reduce((m, i) => Math.max(m, itemPrice(i)), 0);
const price = Number(bundle.price || 0);
if (kind === 'exact') {
const standard = round2(items.reduce((s, i) => s + itemPrice(i) * Number(i.quantity || 0), 0));
return { standardValue: standard, savingText: standard - price > 0.005 ? `save ${fmtR(standard - price)}` : null };
}
if (kind === 'mix') {
const standard = round2(dearest * Number(bundle.setSize || 0));
return { standardValue: standard, savingText: standard - price > 0.005 ? `save up to ${fmtR(standard - price)}` : null };
}
if (kind === 'free') {
return { standardValue: null, savingText: dearest > 0 ? `save up to ${fmtR(dearest * Number(bundle.freeCount || 0))} a set` : null };
}
if (bundle.groupPricing === 'per_ticket' && dearest - price > 0.005) {
return { standardValue: null, savingText: `save up to ${fmtR(dearest - price)} a ticket` };
}
return { standardValue: null, savingText: null };
}
// Items with prices loaded, so the standard value can be shown next to the bundle price.
const DETAIL_INCLUDE = {
items: {
include: {
eventOption: { select: { id: true, name: true, price: true, variants: { select: { id: true, price: true } } } },
variant: { select: { id: true, name: true } },
},
},
};
function shape(bundle, now = new Date()) {
const items = (bundle.items || []).map(({ eventOption, ...i }) => ({ ...i, eventOption: eventOption ? { id: eventOption.id, name: eventOption.name, price: eventOption.price } : null }));
return {
...bundle,
items,
summary: describeBundleItems(bundle),
headline: describeBundle(bundle),
...valueOf(bundle),
available: isBundleAvailable(bundle, now),
};
}
const PRICING_FIELDS = ['kind', 'price', 'setSize', 'freeCount', 'groupPricing', 'items'];
/**
* Turns a create/update body into Prisma data plus the item list. On update, fields
* left out of the body are left alone; when any pricing field is sent, the bundle as a
* whole (saved values plus the new ones) is checked. Items must belong to the event.
*/
async function parseBundleBody(body, eventId, { isCreate, current = null }) {
const data = {};
if (isCreate || body.name !== undefined) {
const name = String(body.name || '').trim();
if (!name) throw new Error('Give the bundle a name');
if (name.length > 80) throw new Error('Keep the name under 80 characters');
data.name = name;
}
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
if (body.isActive !== undefined) data.isActive = !!body.isActive;
if (body.order !== undefined) data.order = Number.isInteger(Number(body.order)) ? Number(body.order) : 0;
const validFrom = optionalDate(body.validFrom, 'Available from');
const validUntil = optionalDate(body.validUntil, 'Available until');
if (validFrom !== undefined) data.validFrom = validFrom;
if (validUntil !== undefined) data.validUntil = validUntil;
if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Available from" must be before "Available until"');
let items;
if (!isCreate && !PRICING_FIELDS.some(f => body[f] !== undefined)) return { data, items };
const pick = (f) => (body[f] !== undefined ? body[f] : current?.[f]);
const kind = pick('kind') || 'exact';
if (!KINDS.includes(kind)) throw new Error('Unknown bundle type');
data.kind = kind;
const rawItems = body.items !== undefined ? body.items : (current?.items || []);
// The same option/variant twice becomes one line.
const merged = new Map();
for (const it of Array.isArray(rawItems) ? rawItems : []) {
if (!it || !it.eventOptionId) continue;
const quantity = kind === 'exact' ? Number(it.quantity) : 1;
if (!Number.isInteger(quantity) || quantity < 1) throw new Error('Each ticket needs a quantity of at least 1');
const key = `${it.eventOptionId}::${it.variantId || ''}`;
const prev = merged.get(key);
merged.set(key, { eventOptionId: it.eventOptionId, variantId: it.variantId || null, quantity: kind === 'exact' ? (prev?.quantity || 0) + quantity : 1 });
}
items = [...merged.values()];
if (items.length === 0) throw new Error(kind === 'exact' ? 'Add at least one ticket to the bundle' : 'Choose at least one qualifying ticket');
const price = Number(pick('price'));
const setSize = Number(pick('setSize'));
const freeCount = Number(pick('freeCount'));
const groupPricing = pick('groupPricing');
const priceGiven = pick('price') !== '' && pick('price') !== null && pick('price') !== undefined;
if (kind === 'exact') {
if (items.reduce((s, i) => s + i.quantity, 0) < 2) throw new Error('A bundle needs at least two tickets in it');
if (!priceGiven || !(price >= 0)) throw new Error('Enter the bundle price (zero or more)');
Object.assign(data, { price: round2(price), setSize: null, freeCount: null, groupPricing: null });
} else if (kind === 'mix') {
if (!Number.isInteger(setSize) || setSize < 2) throw new Error('A pick-any bundle needs at least 2 tickets per set');
if (!priceGiven || !(price >= 0)) throw new Error('Enter the price for the set (zero or more)');
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing: null });
} else if (kind === 'free') {
if (!Number.isInteger(freeCount) || freeCount < 1) throw new Error('At least one ticket must be free');
if (!Number.isInteger(setSize) || setSize <= freeCount) throw new Error('Buy at least one ticket to get the free ones');
Object.assign(data, { price: 0, setSize, freeCount, groupPricing: null });
} else {
if (!Number.isInteger(setSize) || setSize < 2) throw new Error('A group needs at least 2 tickets');
if (!GROUP_PRICING.includes(groupPricing)) throw new Error('Choose a price per ticket or a percentage off');
if (groupPricing === 'percent' && !(price > 0 && price <= 100)) throw new Error('Enter a percentage between 1 and 100');
if (groupPricing === 'per_ticket' && (!priceGiven || !(price >= 0))) throw new Error('Enter the price per ticket (zero or more)');
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing });
}
const options = await prisma.eventOption.findMany({
where: { id: { in: items.map(i => i.eventOptionId) } },
select: { id: true, eventId: true, variants: { select: { id: true } } },
});
for (const i of items) {
const row = options.find(o => o.id === i.eventOptionId);
if (!row || row.eventId !== eventId) throw new Error('One of the tickets isn\'t part of this event');
if (i.variantId && !row.variants.some(v => v.id === i.variantId)) throw new Error('One of the selected variants no longer exists');
}
return { data, items };
}
async function eventOr404(eventId, res) {
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, title: true } });
if (!event) { res.status(404); throw new Error('Event not found'); }
return event;
}
// @desc An event's bundle deals with how often each has been used
// @route GET /api/bundles/event/:eventId
// @access Private/Supervisor
const listBundles = async (req, res) => {
try {
await eventOr404(req.params.eventId, res);
const bundles = await prisma.eventBundle.findMany({
where: { eventId: req.params.eventId },
include: DETAIL_INCLUDE,
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
});
const totals = await prisma.registrationBundle.groupBy({
by: ['bundleId'],
where: { bundle: { eventId: req.params.eventId }, registration: { status: { not: 'cancelled' } } },
_sum: { count: true, discount: true },
_count: { _all: true },
});
const byId = Object.fromEntries(totals.map(t => [t.bundleId, t]));
const now = new Date();
res.json(bundles.map(b => ({
...shape(b, now),
uses: byId[b.id]?._sum?.count || 0,
registrations: byId[b.id]?._count?._all || 0,
totalDiscount: round2(byId[b.id]?._sum?.discount || 0),
})));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Create a bundle deal
// @route POST /api/bundles/event/:eventId
// @access Private/Supervisor
const createBundle = async (req, res) => {
try {
const event = await eventOr404(req.params.eventId, res);
let parsed;
try {
parsed = await parseBundleBody(req.body || {}, event.id, { isCreate: true });
} catch (e) { res.status(400); throw e; }
const bundle = await prisma.eventBundle.create({
data: { ...parsed.data, eventId: event.id, items: { create: parsed.items } },
include: DETAIL_INCLUDE,
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_created',
targetType: 'EventBundle', targetId: bundle.id,
metadata: { eventId: event.id, name: bundle.name, kind: bundle.kind, deal: describeBundle(bundle) },
ip: getClientIp(req),
});
res.status(201).json({ ...shape(bundle), uses: 0, registrations: 0, totalDiscount: 0 });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Edit a bundle deal (including switching it off). Registrations that already
// have it are only re-worked when their own items change.
// @route PUT /api/bundles/:id
// @access Private/Supervisor
const updateBundle = async (req, res) => {
try {
const existing = await prisma.eventBundle.findUnique({ where: { id: req.params.id }, include: { items: true } });
if (!existing) { res.status(404); throw new Error('Bundle not found'); }
let parsed;
try {
parsed = await parseBundleBody(req.body || {}, existing.eventId, { isCreate: false, current: existing });
} catch (e) { res.status(400); throw e; }
const bundle = await prisma.eventBundle.update({
where: { id: existing.id },
data: { ...parsed.data, ...(parsed.items ? { items: { deleteMany: {}, create: parsed.items } } : {}) },
include: DETAIL_INCLUDE,
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_updated',
targetType: 'EventBundle', targetId: bundle.id,
metadata: { eventId: bundle.eventId, name: bundle.name, changedKeys: Object.keys(req.body || {}) },
ip: getClientIp(req),
});
res.json(shape(bundle));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Delete a bundle deal. One that's been used is switched off instead, so the
// registrations that got it keep their price.
// @route DELETE /api/bundles/:id
// @access Private/Supervisor
const deleteBundle = async (req, res) => {
try {
const existing = await prisma.eventBundle.findUnique({ where: { id: req.params.id } });
if (!existing) { res.status(404); throw new Error('Bundle not found'); }
const used = await prisma.registrationBundle.count({ where: { bundleId: existing.id } });
if (used > 0) {
await prisma.eventBundle.update({ where: { id: existing.id }, data: { isActive: false } });
} else {
await prisma.eventBundle.delete({ where: { id: existing.id } });
}
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_deleted',
targetType: 'EventBundle', targetId: existing.id,
metadata: { eventId: existing.eventId, name: existing.name, switchedOff: used > 0 },
ip: getClientIp(req),
});
res.json(used > 0
? { switchedOff: true, message: 'This bundle has been used, so it\'s been switched off instead. Registrations that got it keep their price.' }
: { deleted: true, message: 'Bundle deleted' });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc The bundle deals a basket gets, before any coupon code
// @route POST /api/bundles/quote
// @access Public. Body: { eventId, options: [{ eventOptionId, variantId?, quantity }], forUserId? }
const quoteBundles = async (req, res) => {
try {
const { eventId, options, forUserId } = req.body || {};
if (!eventId || !Array.isArray(options)) { res.status(400); throw new Error('eventId and options are required'); }
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
});
if (!event) { res.status(404); throw new Error('Event not found'); }
const { buyer } = await basketBuyer(req, forUserId);
const lines = await priceBasket(event, options, buyer);
const subtotal = round2(lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0));
const bundles = await previewBundles(eventId, lines);
res.json({
subtotal,
bundleDiscount: bundles.discount,
bundles: bundles.applications,
total: round2(Math.max(0, subtotal - bundles.discount)),
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
/** The deals to show on an event's page: available bundles, without internal fields. */
async function publicBundlesForEvent(eventId) {
const bundles = await prisma.eventBundle.findMany({
where: { eventId, isActive: true },
include: DETAIL_INCLUDE,
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
});
const now = new Date();
return bundles.filter(b => isBundleAvailable(b, now)).map(b => {
const s = shape(b, now);
return {
id: s.id, name: s.name, description: s.description, validUntil: s.validUntil,
kind: s.kind, price: s.price, setSize: s.setSize, freeCount: s.freeCount, groupPricing: s.groupPricing,
summary: s.summary, headline: s.headline, standardValue: s.standardValue, savingText: s.savingText,
items: s.items.map(i => ({ eventOptionId: i.eventOptionId, variantId: i.variantId, quantity: i.quantity })),
};
});
}
module.exports = { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles, publicBundlesForEvent };
module.exports = { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles, publicBundlesForEvent: deals.publicBundlesForEvent };
+23 -188
View File
@@ -1,193 +1,28 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
// Campuses (v1). The logic is in services/campuses.ts, shared with /api/v2 (1.51.9).
const campuses = require('../services/campuses');
const { getClientIp } = require('../utils/requestUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
const { SETTING_KEY, CAMPUS_PUBLIC_SELECT, campusesEnabled, cleanCampusInput, campusAudienceWhere } = require('../utils/campuses');
const { scopeFor } = require('../utils/campusScope');
const audit = (req, action, campus, metadata) => logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'Campus', targetId: campus?.id, metadata: { name: campus?.name, ...metadata }, ip: getClientIp(req),
});
const ctxOf = async (req) => ({ scope: await scopeFor(req), ip: getClientIp(req) });
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message, ...(error.code && !/^P\d/.test(error.code) ? { code: error.code } : {}) });
// A slug that's already taken → a message instead of a Prisma error.
function friendlyError(e) {
if (e?.code === 'P2002') return 'Another campus already uses that web address — choose a different one.';
return e.message || safeErrorMessage(e);
}
const ADMIN_INCLUDE = {
notifyRecipients: { select: { id: true, name: true, email: true } },
_count: { select: { events: true, homeOf: true, scopedStaff: true } },
};
// @desc Whether campuses are on, and the active campuses (public site filters, event cards)
// @route GET /api/campuses
// @access Public
const getCampuses = async (req, res) => {
try {
const enabled = await campusesEnabled();
const campuses = enabled
? await prisma.campus.findMany({ where: { archivedAt: null }, select: CAMPUS_PUBLIC_SELECT, orderBy: [{ order: 'asc' }, { name: 'asc' }] })
: [];
res.json({ enabled, campuses });
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc A campus landing page: the campus (active only)
// @route GET /api/campuses/by-slug/:slug
// @access Public
const getCampusBySlug = async (req, res) => {
try {
const campus = (await campusesEnabled())
? await prisma.campus.findFirst({ where: { slug: String(req.params.slug), archivedAt: null }, select: CAMPUS_PUBLIC_SELECT })
: null;
if (!campus) return res.status(404).json({ message: 'Campus not found' });
res.json(campus);
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc All campuses with archived ones, event counts and summary recipients (Site settings → Campuses)
// @route GET /api/campuses/admin
// @access Private/Admin
const getCampusesAdmin = async (req, res) => {
try {
const [enabled, campuses] = await Promise.all([
campusesEnabled(),
prisma.campus.findMany({ include: ADMIN_INCLUDE, orderBy: [{ archivedAt: { sort: 'asc', nulls: 'first' } }, { order: 'asc' }, { name: 'asc' }] }),
]);
res.json({ enabled, campuses });
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc Turn campuses on or off (off hides campus fields and filters; nothing is deleted)
// @route PUT /api/campuses/settings body: { enabled }
// @access Private/Admin
const updateCampusSettings = async (req, res) => {
try {
const enabled = req.body?.enabled === true;
await prisma.appSetting.upsert({ where: { key: SETTING_KEY }, update: { value: String(enabled) }, create: { key: SETTING_KEY, value: String(enabled) } });
invalidateSettingsCache();
await warmCache();
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'campuses_toggled', targetType: 'AppSetting', metadata: { enabled }, ip: getClientIp(req) });
res.json({ enabled });
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc Add a campus (added at the end of the list)
// @route POST /api/campuses
// @access Private/Admin
const createCampus = async (req, res) => {
try {
const { data, error } = cleanCampusInput(req.body);
if (error) return res.status(400).json({ message: error });
const last = await prisma.campus.aggregate({ _max: { order: true } });
const campus = await prisma.campus.create({ data: { ...data, order: (last._max.order ?? -1) + 1 }, include: ADMIN_INCLUDE });
audit(req, 'campus_created', campus, { slug: campus.slug });
res.status(201).json(campus);
} catch (e) {
res.status(e?.code === 'P2002' ? 409 : 400).json({ message: friendlyError(e) });
}
};
// @desc Change a campus: details, summary recipients, archived or not
// @route PUT /api/campuses/:id body: campus fields, notifyRecipientIds?, archived?
// @access Private/Admin
const updateCampus = async (req, res) => {
try {
const existing = await prisma.campus.findUnique({ where: { id: req.params.id } });
if (!existing) return res.status(404).json({ message: 'Campus not found' });
const { data, error } = cleanCampusInput(req.body, { partial: true });
if (error) return res.status(400).json({ message: error });
if (Array.isArray(req.body?.notifyRecipientIds)) {
const ids = req.body.notifyRecipientIds.filter(x => typeof x === 'string' && x);
// Summaries go to people who run events — never to ordinary users.
const staff = await prisma.user.findMany({ where: { id: { in: ids }, role: { in: ['admin', 'supervisor', 'staff'] } }, select: { id: true } });
data.notifyRecipients = { set: staff.map(u => ({ id: u.id })) };
}
let archiveChange = null;
if (req.body?.archived !== undefined) {
const archived = req.body.archived === true;
if (archived !== !!existing.archivedAt) {
data.archivedAt = archived ? new Date() : null;
archiveChange = archived;
}
}
const campus = await prisma.campus.update({ where: { id: existing.id }, data, include: ADMIN_INCLUDE });
if (archiveChange !== null) audit(req, 'campus_archived', campus, { archived: archiveChange });
const changed = Object.keys(data).filter(k => k !== 'archivedAt');
if (changed.length) audit(req, 'campus_updated', campus, { changed });
res.json(campus);
} catch (e) {
res.status(e?.code === 'P2002' ? 409 : 400).json({ message: friendlyError(e) });
}
};
// @desc Put the campuses in this order (filters and lists follow it)
// @route PUT /api/campuses/order body: { ids: [...] }
// @access Private/Admin
const reorderCampuses = async (req, res) => {
try {
const ids = Array.isArray(req.body?.ids) ? req.body.ids.filter(x => typeof x === 'string') : [];
if (!ids.length) return res.status(400).json({ message: 'Send the campus ids in their new order.' });
await prisma.$transaction(ids.map((id, order) => prisma.campus.updateMany({ where: { id }, data: { order } })));
res.json({ ok: true });
} catch (e) {
res.status(400).json({ message: safeErrorMessage(e) });
}
};
// @desc Delete a campus that has no events (otherwise archive it). Home campus is cleared.
// @route DELETE /api/campuses/:id
// @access Private/Admin
const deleteCampus = async (req, res) => {
try {
const campus = await prisma.campus.findUnique({ where: { id: req.params.id }, include: { _count: { select: { events: true, scopedStaff: true } } } });
if (!campus) return res.status(404).json({ message: 'Campus not found' });
// Deleting would drop their limit and leave them unscoped (seeing every campus).
if (campus._count.scopedStaff > 0) {
return res.status(409).json({ code: 'CAMPUS_HAS_SCOPED_STAFF', message: `${campus._count.scopedStaff} staff member${campus._count.scopedStaff === 1 ? ' is' : 's are'} limited to ${campus.name}. Change their campuses in Admin → Users first, or archive the campus.` });
}
if (campus._count.events > 0) {
return res.status(409).json({ code: 'CAMPUS_HAS_EVENTS', message: `${campus.name} has ${campus._count.events} event${campus._count.events === 1 ? '' : 's'}, so it can't be deleted. Archive it instead.` });
}
await prisma.campus.delete({ where: { id: campus.id } });
audit(req, 'campus_deleted', campus, { slug: campus.slug });
res.json({ message: 'Campus deleted' });
} catch (e) {
res.status(400).json({ message: safeErrorMessage(e) });
}
};
// @desc "Everyone at campus X" for the email/WhatsApp composers: home campus X, plus (with
// ?registrants=true) people registered for X's events in the last 12 months. The
// composer sends to these ids as usual, so news consent is still checked on sending.
// @route GET /api/campuses/:id/audience?registrants=true
// @access Private/Supervisor
const getCampusAudience = async (req, res) => {
try {
const campus = await prisma.campus.findUnique({ where: { id: req.params.id }, select: { id: true, name: true } });
if (!campus) return res.status(404).json({ message: 'Campus not found' });
const scope = await require('../utils/campusScope').scopeFor(req);
if (scope && !scope.campusIds.includes(campus.id)) return res.status(404).json({ message: 'Campus not found' });
const users = await prisma.user.findMany({
where: campusAudienceWhere(campus.id, { includeRegistrants: req.query.registrants === 'true' }),
select: { id: true, name: true, email: true, phoneNumber: true },
orderBy: { name: 'asc' },
});
res.json({ campus, users });
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @route GET /api/campuses — whether they're on, and the active ones
const getCampuses = async (req, res) => { try { res.json(await campuses.publicCampuses()); } catch (e) { fail(res, e); } };
// @route GET /api/campuses/by-slug/:slug
const getCampusBySlug = async (req, res) => { try { res.json(await campuses.campusBySlug(req.params.slug)); } catch (e) { fail(res, e); } };
// @route GET /api/campuses/admin
const getCampusesAdmin = async (req, res) => { try { res.json(await campuses.adminCampuses()); } catch (e) { fail(res, e); } };
// @route PUT /api/campuses/settings { enabled }
const updateCampusSettings = async (req, res) => { try { res.json(await campuses.setCampusesEnabled(req.body?.enabled === true, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route POST /api/campuses
const createCampus = async (req, res) => { try { res.status(201).json(await campuses.createCampus(req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route PUT /api/campuses/:id
const updateCampus = async (req, res) => { try { res.json(await campuses.updateCampus(req.params.id, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route PUT /api/campuses/order { ids }
const reorderCampuses = async (req, res) => { try { await campuses.reorderCampuses(req.body?.ids); res.json({ ok: true }); } catch (e) { fail(res, e); } };
// @route DELETE /api/campuses/:id
const deleteCampus = async (req, res) => { try { await campuses.deleteCampus(req.params.id, req.user, await ctxOf(req)); res.json({ message: 'Campus deleted' }); } catch (e) { fail(res, e); } };
// @route GET /api/campuses/:id/audience?registrants=true
const getCampusAudience = async (req, res) => { try { res.json(await campuses.campusAudience(req.params.id, req.query.registrants === 'true', await ctxOf(req))); } catch (e) { fail(res, e); } };
module.exports = { getCampusAudience, getCampuses, getCampusBySlug, getCampusesAdmin, updateCampusSettings, createCampus, updateCampus, reorderCampuses, deleteCampus };
+15 -148
View File
@@ -1,177 +1,44 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
// Planned event costs (cashup and the profit report). Shared with /api/v2 (services/setup.ts, 1.51.8).
const setup = require('../services/setup');
const { safeErrorMessage } = require('../utils/errorUtils');
const { assertEventOpen, ALL_METHODS } = require('../utils/cashupUtils');
function normalizePaidFromMethod(value) {
if (value === undefined) return undefined;
if (value === null || value === '') return null;
if (!ALL_METHODS.includes(value)) {
throw new Error("paidFromMethod must be one of 'cash', 'card', 'eft', 'other', or null");
}
return value;
}
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
// @desc List costs for an event
// @route GET /api/events/:eventId/costs
// @access Private/Supervisor
const getEventCosts = async (req, res) => {
try {
const costs = await prisma.eventCost.findMany({
where: { eventId: req.params.eventId },
include: { eventOption: { select: { id: true, name: true } } },
orderBy: { createdAt: 'asc' }
});
res.json(costs);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
res.json(await setup.listCosts(req.params.eventId));
} catch (error) { fail(res, error); }
};
// @desc Create a cost for an event
// @route POST /api/events/:eventId/costs
// @access Private/Supervisor
// @access Private/Admin
const createEventCost = async (req, res) => {
try {
const { eventId } = req.params;
const { label, costType, amount, eventOptionId, notes, paidFromMethod } = req.body;
await assertEventOpen(eventId, res);
if (!label || !String(label).trim()) {
res.status(400);
throw new Error('Label is required');
}
if (costType !== 'once_off' && costType !== 'per_item') {
res.status(400);
throw new Error("costType must be 'once_off' or 'per_item'");
}
const amt = parseFloat(amount);
if (!(amt >= 0)) {
res.status(400);
throw new Error('Amount must be a non-negative number');
}
if (costType === 'per_item' && !eventOptionId) {
res.status(400);
throw new Error('eventOptionId is required for per-item costs');
}
if (costType === 'per_item') {
const option = await prisma.eventOption.findUnique({ where: { id: eventOptionId } });
if (!option || option.eventId !== eventId) {
res.status(404);
throw new Error('Ticket type not found for this event');
}
}
const cost = await prisma.eventCost.create({
data: {
id: uuidv4(),
eventId,
label: String(label).trim(),
costType,
amount: amt,
eventOptionId: costType === 'per_item' ? eventOptionId : null,
paidFromMethod: normalizePaidFromMethod(paidFromMethod) ?? null,
notes: notes || null
},
include: { eventOption: { select: { id: true, name: true } } }
});
res.status(201).json(cost);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
res.status(201).json(await setup.createCost(req.params.eventId, req.body || {}));
} catch (error) { fail(res, error); }
};
// @desc Update a cost
// @route PUT /api/costs/:id
// @access Private/Supervisor
// @access Private/Admin
const updateEventCost = async (req, res) => {
try {
const existing = await prisma.eventCost.findUnique({ where: { id: req.params.id } });
if (!existing) {
res.status(404);
throw new Error('Cost not found');
}
await assertEventOpen(existing.eventId, res);
const { label, costType, amount, eventOptionId, notes, paidFromMethod } = req.body;
const nextCostType = costType !== undefined ? costType : existing.costType;
if (nextCostType !== 'once_off' && nextCostType !== 'per_item') {
res.status(400);
throw new Error("costType must be 'once_off' or 'per_item'");
}
const nextEventOptionId = nextCostType === 'per_item'
? (eventOptionId !== undefined ? eventOptionId : existing.eventOptionId)
: null;
if (nextCostType === 'per_item') {
if (!nextEventOptionId) {
res.status(400);
throw new Error('eventOptionId is required for per-item costs');
}
const option = await prisma.eventOption.findUnique({ where: { id: nextEventOptionId } });
if (!option || option.eventId !== existing.eventId) {
res.status(404);
throw new Error('Ticket type not found for this event');
}
}
let nextAmount = existing.amount;
if (amount !== undefined) {
const amt = parseFloat(amount);
if (!(amt >= 0)) {
res.status(400);
throw new Error('Amount must be a non-negative number');
}
nextAmount = amt;
}
const nextPaidFromMethod = normalizePaidFromMethod(paidFromMethod);
const cost = await prisma.eventCost.update({
where: { id: req.params.id },
data: {
label: label !== undefined ? String(label).trim() : existing.label,
costType: nextCostType,
amount: nextAmount,
eventOptionId: nextEventOptionId,
paidFromMethod: nextPaidFromMethod !== undefined ? nextPaidFromMethod : existing.paidFromMethod,
notes: notes !== undefined ? notes : existing.notes
},
include: { eventOption: { select: { id: true, name: true } } }
});
res.json(cost);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
res.json(await setup.updateCost(req.params.id, req.body || {}));
} catch (error) { fail(res, error); }
};
// @desc Delete a cost
// @route DELETE /api/costs/:id
// @access Private/Supervisor
// @access Private/Admin
const deleteEventCost = async (req, res) => {
try {
const existing = await prisma.eventCost.findUnique({ where: { id: req.params.id } });
if (!existing) {
res.status(404);
throw new Error('Cost not found');
}
await assertEventOpen(existing.eventId, res);
await prisma.eventCost.delete({ where: { id: req.params.id } });
await setup.deleteCost(req.params.id);
res.json({ message: 'Cost deleted' });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
} catch (error) { fail(res, error); }
};
module.exports = {
getEventCosts,
createEventCost,
updateEventCost,
deleteEventCost
};
module.exports = { getEventCosts, createEventCost, updateEventCost, deleteEventCost };
+24 -379
View File
@@ -1,392 +1,37 @@
const prisma = require('../config/db');
// Coupon codes (v1). The logic is in services/deals.ts, shared with /api/v2 (1.51.9).
const deals = require('../services/deals');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { listWhere } = require('../utils/campusScope');
const { getClientIp } = require('../utils/requestUtils');
const { normalizeCode, previewCoupon, buildCouponLines, describeCoupon, countCouponUses } = require('../utils/coupons');
const { resolveOptionPrice, resolveVariantTierPrice } = require('../utils/pricing');
const { describeBundle } = require('../utils/bundles');
const { scopeFor, couponAllowed, listWhere } = require('../utils/campusScope');
const { scopeFor } = require('../utils/campusScope');
const TYPES = ['percent', 'fixed', 'comp'];
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
const ctxOf = async (req) => ({ scope: await scopeFor(req), ip: getClientIp(req) });
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: safeErrorMessage(error), ...(error.code && !/^P\d/.test(error.code) ? { code: error.code } : {}) });
const COUPON_DETAIL_INCLUDE = {
events: { include: { event: { select: { id: true, title: true, startDate: true, campusId: true } } } },
options: { include: { eventOption: { select: { id: true, name: true, eventId: true } }, variant: { select: { id: true, name: true } } } },
allowedUsers: { include: { user: { select: { id: true, name: true, email: true } } } },
createdBy: { select: { id: true, name: true } },
};
const actorOf = (req) => (req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null);
const dateOrNull = (v) => { if (!v) return null; const d = new Date(v); return isNaN(d.getTime()) ? null : d; };
// Campus-scoped supervisors: only coupons limited to events at their campuses (couponAllowed).
const couponCampuses = coupon => (coupon.events || []).map(ce => ce.event?.campusId ?? null);
async function eventIdsAllowed(scope, eventIds) {
if (!scope) return true;
if (!eventIds || eventIds.length === 0) return false; // a coupon for every event
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { campusId: true } });
return events.length === eventIds.length && couponAllowed(scope, events.map(e => e.campusId));
}
const SCOPED_COUPON_MSG = 'Supervisors for some campuses can only make coupons limited to events at their campuses.';
const optionalDate = (v, label) => {
if (v === undefined) return undefined;
if (v === null || v === '') return null;
const d = new Date(v);
if (isNaN(d.getTime())) throw new Error(`${label} is not a valid date`);
return d;
};
const optionalInt = (v, label, min = 0) => {
if (v === undefined) return undefined;
if (v === null || v === '') return null;
const n = Number(v);
if (!Number.isInteger(n) || n < min) throw new Error(`${label} must be a whole number of at least ${min}`);
return n;
};
/**
* Turns a create/update body into Prisma data plus the relation lists. On update,
* fields left out of the body are left alone.
*/
async function parseCouponBody(body, { isCreate }) {
const data = {};
if (isCreate || body.code !== undefined) {
const code = normalizeCode(body.code);
if (!/^[A-Z0-9_-]{3,32}$/.test(code)) {
throw new Error('Codes are 3–32 letters, numbers, hyphens or underscores');
}
data.code = code;
}
if (isCreate || body.type !== undefined) {
if (!TYPES.includes(body.type)) throw new Error("Discount type must be 'percent', 'fixed' or 'comp'");
data.type = body.type;
}
if (isCreate || body.value !== undefined) {
const type = data.type || body.type;
const value = type === 'comp' ? 0 : Number(body.value);
if (type !== 'comp' && !(value > 0)) throw new Error('Enter a discount value greater than zero');
if (type === 'percent' && value > 100) throw new Error('A percentage discount can\'t be more than 100');
data.value = value;
}
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
if (body.stackEarlyBird !== undefined) data.stackEarlyBird = !!body.stackEarlyBird;
if (body.active !== undefined) data.active = !!body.active;
const validFrom = optionalDate(body.validFrom, 'Valid from');
const validUntil = optionalDate(body.validUntil, 'Valid until');
if (validFrom !== undefined) data.validFrom = validFrom;
if (validUntil !== undefined) data.validUntil = validUntil;
if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Valid from" must be before "Valid until"');
const maxUses = optionalInt(body.maxUses, 'Max total uses', 1);
const maxUsesPerUser = optionalInt(body.maxUsesPerUser, 'Max uses per person', 1);
const minQuantity = optionalInt(body.minQuantity, 'Minimum quantity', 1);
if (maxUses !== undefined) data.maxUses = maxUses;
if (maxUsesPerUser !== undefined) data.maxUsesPerUser = maxUsesPerUser;
if (minQuantity !== undefined) data.minQuantity = minQuantity;
if (body.restrictRoles !== undefined) {
const roles = Array.isArray(body.restrictRoles) ? body.restrictRoles : [];
if (roles.some(r => !ROLES.includes(r))) throw new Error('Unknown role in restrictions');
data.restrictRoles = roles;
}
if (body.restrictDomain !== undefined) {
const domain = String(body.restrictDomain || '').trim().toLowerCase().replace(/^@/, '');
if (domain && !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) throw new Error('Enter an email domain like church.org');
data.restrictDomain = domain || null;
}
const eventIds = body.eventIds !== undefined ? [...new Set((body.eventIds || []).filter(Boolean))] : undefined;
const scopes = body.options !== undefined
? (body.options || []).filter(o => o && o.eventOptionId).map(o => ({ eventOptionId: o.eventOptionId, variantId: o.variantId || null }))
: undefined;
const userIds = body.userIds !== undefined ? [...new Set((body.userIds || []).filter(Boolean))] : undefined;
if (scopes && scopes.length > 0) {
const optionRows = await prisma.eventOption.findMany({
where: { id: { in: scopes.map(s => s.eventOptionId) } },
select: { id: true, eventId: true, variants: { select: { id: true } } },
});
for (const s of scopes) {
const row = optionRows.find(o => o.id === s.eventOptionId);
if (!row) throw new Error('One of the selected ticket options no longer exists');
if (s.variantId && !row.variants.some(v => v.id === s.variantId)) throw new Error('One of the selected variants no longer exists');
// A scoped option implies its event, so the code can't be used elsewhere by mistake.
if (eventIds && eventIds.length > 0 && !eventIds.includes(row.eventId)) {
throw new Error('Selected ticket options must belong to the selected events');
}
}
}
return { data, eventIds, scopes, userIds };
}
function relationWrites({ eventIds, scopes, userIds }, isCreate) {
const out = {};
if (eventIds !== undefined) out.events = { ...(isCreate ? {} : { deleteMany: {} }), create: eventIds.map(eventId => ({ eventId })) };
if (scopes !== undefined) out.options = { ...(isCreate ? {} : { deleteMany: {} }), create: scopes };
if (userIds !== undefined) out.allowedUsers = { ...(isCreate ? {} : { deleteMany: {} }), create: userIds.map(userId => ({ userId })) };
return out;
}
// @desc List coupons with their use counts
// @route GET /api/coupons
// @access Private/Supervisor
const listCoupons = async (req, res) => {
try {
const scope = await scopeFor(req);
const coupons = (await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } }))
.filter(c => couponAllowed(scope, couponCampuses(c)));
const totals = await prisma.couponRedemption.groupBy({
by: ['couponId'],
where: { releasedAt: null, registration: { status: { not: 'cancelled' } } },
_count: { _all: true },
_sum: { discountAmount: true },
});
const byId = Object.fromEntries(totals.map(t => [t.couponId, t]));
res.json(coupons.map(c => ({
...c,
summary: describeCoupon(c),
uses: byId[c.id]?._count?._all || 0,
totalDiscount: byId[c.id]?._sum?.discountAmount || 0,
})));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Get one coupon with its redemptions
// @route GET /api/coupons/:id
// @access Private/Supervisor
const getCoupon = async (req, res) => {
try {
const coupon = await prisma.coupon.findUnique({
where: { id: req.params.id },
include: {
...COUPON_DETAIL_INCLUDE,
redemptions: {
orderBy: { createdAt: 'desc' },
include: {
user: { select: { id: true, name: true, email: true } },
appliedBy: { select: { id: true, name: true } },
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
},
},
},
});
if (!coupon || !couponAllowed(await scopeFor(req), couponCampuses(coupon))) { res.status(404); throw new Error('Coupon not found'); }
res.json({ ...coupon, summary: describeCoupon(coupon) });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Create a coupon
// @route POST /api/coupons
// @access Private/Supervisor
const createCoupon = async (req, res) => {
try {
let parsed;
try {
parsed = await parseCouponBody(req.body || {}, { isCreate: true });
} catch (e) { res.status(400); throw e; }
if (!(await eventIdsAllowed(await scopeFor(req), parsed.eventIds))) { res.status(403); throw new Error(SCOPED_COUPON_MSG); }
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
}
const coupon = await prisma.coupon.create({
data: { ...parsed.data, createdById: req.user.id, ...relationWrites(parsed, true) },
include: COUPON_DETAIL_INCLUDE,
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'coupon_created',
targetType: 'Coupon', targetId: coupon.id,
metadata: { code: coupon.code, type: coupon.type, value: coupon.value },
ip: getClientIp(req),
});
res.status(201).json({ ...coupon, summary: describeCoupon(coupon), uses: 0, totalDiscount: 0 });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Update a coupon (including turning it off). Discounts already applied keep
// their locked amount.
// @route PUT /api/coupons/:id
// @access Private/Supervisor
const updateCoupon = async (req, res) => {
try {
const existing = await prisma.coupon.findUnique({ where: { id: req.params.id }, include: { events: { include: { event: { select: { campusId: true } } } } } });
const scope = await scopeFor(req);
if (!existing || !couponAllowed(scope, couponCampuses(existing))) { res.status(404); throw new Error('Coupon not found'); }
let parsed;
try {
parsed = await parseCouponBody({ type: existing.type, ...req.body }, { isCreate: false });
} catch (e) { res.status(400); throw e; }
if (parsed.eventIds !== undefined && !(await eventIdsAllowed(scope, parsed.eventIds))) { res.status(403); throw new Error(SCOPED_COUPON_MSG); }
if (parsed.data.code && parsed.data.code !== existing.code) {
const used = await prisma.couponRedemption.count({ where: { couponId: existing.id } });
if (used > 0) { res.status(400); throw new Error('This code has been used, so it can\'t be renamed. Turn it off and create a new one instead.'); }
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
}
}
const coupon = await prisma.coupon.update({
where: { id: existing.id },
data: { ...parsed.data, ...relationWrites(parsed, false) },
include: COUPON_DETAIL_INCLUDE,
});
const disabled = existing.active && coupon.active === false;
const changedKeys = Object.keys(req.body || {});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role,
action: disabled && changedKeys.length === 1 ? 'coupon_disabled' : 'coupon_updated',
targetType: 'Coupon', targetId: coupon.id,
metadata: { code: coupon.code, changedKeys },
ip: getClientIp(req),
});
const { usesTotal } = await countCouponUses(coupon.id, null);
res.json({ ...coupon, summary: describeCoupon(coupon), uses: usesTotal });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
/**
* Who a basket is being priced for: the logged-in buyer, or, for staff, the attendee
* (`forUserId`) or a walk-in with no account yet (`forUserId: null`).
*/
async function basketBuyer(req, forUserId) {
const isStaff = !!(req.user && STAFF_ROLES.includes(req.user.role));
let buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
if (isStaff && forUserId) {
buyer = await prisma.user.findUnique({ where: { id: forUserId }, select: { id: true, role: true, email: true } });
} else if (isStaff && forUserId === null) {
buyer = null; // a walk-in without an account yet
}
return { buyer, isStaff };
}
/** Prices a basket the same way registration will, as coupon/bundle lines. */
async function priceBasket(event, options, buyer) {
const registrationOptions = [];
// A "pick sessions" line counts once per chosen session (an all-sessions ticket with
// sessions ticked is still one line at one price).
const { isPickOption } = require('../utils/sessions');
const perSession = (o) => isPickOption(event, event.eventOptions.find(x => x.id === o.eventOptionId));
const expanded = (options || []).flatMap(o => (Array.isArray(o.sessionIds) && o.sessionIds.length && perSession(o) ? o.sessionIds.map(sessionId => ({ ...o, sessionId })) : [o]));
for (const o of expanded) {
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
const qty = Number(o.quantity || 0);
if (!eo || qty < 1) continue;
const resolved = o.variantId
? await resolveVariantTierPrice(eo, o.variantId, qty, buyer)
: await resolveOptionPrice(eo, qty, buyer);
registrationOptions.push({
eventOptionId: eo.id, variantId: o.variantId || null, quantity: qty, eventOption: eo,
tranches: [{ quantity: qty, priceSnapshot: resolved.price, appliedTierId: resolved.tierId }],
});
}
return buildCouponLines({ registrationOptions });
}
// @desc Check a code against the items someone is about to register for
// @route POST /api/coupons/validate
// @access Public (login needed for restricted codes)
// Body: { code, eventId, options: [{ eventOptionId, variantId?, quantity }], forUserId? }
// Staff can pass forUserId to check on an attendee's behalf, or leave it out for a new
// attendee — restricted codes are then checked when the registration is saved.
// @route GET /api/coupons — with use counts
const listCoupons = async (req, res) => { try { res.json(await deals.listCoupons(await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route GET /api/coupons/:id — with its redemptions
const getCoupon = async (req, res) => { try { res.json(await deals.getCoupon(req.params.id, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route POST /api/coupons
const createCoupon = async (req, res) => { try { res.status(201).json(await deals.createCoupon(req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route PUT /api/coupons/:id
const updateCoupon = async (req, res) => { try { res.json(await deals.updateCoupon(req.params.id, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route POST /api/coupons/validate — a code against a basket ({ valid: false } answers 400, as before)
const validateCode = async (req, res) => {
try {
const { code, eventId, options, forUserId } = req.body || {};
if (!code || !eventId || !Array.isArray(options) || options.length === 0) {
res.status(400); throw new Error('code, eventId and options are required');
}
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
});
if (!event) { res.status(404); throw new Error('Event not found'); }
const { buyer, isStaff } = await basketBuyer(req, forUserId);
const lines = await priceBasket(event, options, buyer);
const subtotal = lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
try {
const { coupon, discount, bundles } = await previewCoupon({
code, eventId, user: buyer, lines,
deferRestrictions: isStaff && !buyer,
});
res.json({
valid: true,
code: coupon.code,
type: coupon.type,
value: coupon.value,
summary: describeCoupon(coupon),
description: coupon.description,
discount,
bundleDiscount: bundles.discount,
bundles: bundles.applications,
subtotal,
total: Math.max(0, subtotal - bundles.discount - discount),
});
} catch (e) {
if (!e.isCouponError) throw e;
res.status(400).json({ valid: false, message: e.message });
}
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
const result = await deals.checkCode(req.body || {}, actorOf(req));
res.status(result.valid ? 200 : 400).json(result);
} catch (e) { fail(res, e); }
};
// @desc Coupon and bundle-deal uses for the Discounts report
// @route GET /api/coupons/redemptions?eventIds=a,b&from=&to=
// @access Private/Supervisor
// @route GET /api/coupons/redemptions?eventIds=a,b&from=&to= — the Discounts report
const listRedemptions = async (req, res) => {
try {
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean);
const from = req.query.from ? new Date(req.query.from) : null;
const to = req.query.to ? new Date(req.query.to) : null;
const scopeEvent = await listWhere(req, 'event'); // { event: … } or {}
const where = {
releasedAt: null,
registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...scopeEvent },
...(from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {}),
};
const rows = await prisma.couponRedemption.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
coupon: { select: { id: true, code: true, type: true, value: true } },
user: { select: { id: true, name: true, email: true } },
appliedBy: { select: { id: true, name: true } },
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
},
});
// Bundle deals, in the same shape. Their rows are rebuilt whenever a registration's
// discounts are recalculated, so dates go by when the registration was made.
const regDate = from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {};
const bundleRows = await prisma.registrationBundle.findMany({
where: { registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...regDate, ...scopeEvent } },
include: {
bundle: { include: { items: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } } } },
registration: { select: { id: true, status: true, createdAt: true, user: { select: { id: true, name: true, email: true } }, event: { select: { id: true, title: true } } } },
},
});
res.json([
...rows.map(r => ({ ...r, kind: 'coupon', uses: 1, summary: describeCoupon(r.coupon) })),
...bundleRows.map(b => ({
id: b.id,
kind: 'bundle',
uses: b.count,
discountAmount: b.discount,
createdAt: b.registration.createdAt,
summary: describeBundle(b.bundle),
coupon: { id: b.bundleId, code: b.bundle.name },
user: b.registration.user,
appliedBy: null,
registration: { id: b.registration.id, status: b.registration.status, event: b.registration.event },
})),
]);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
res.json(await deals.listRedemptions({ eventIds, from: dateOrNull(req.query.from), to: dateOrNull(req.query.to) }, await listWhere(req, 'event')));
} catch (e) { fail(res, e); }
};
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions, basketBuyer, priceBasket };
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions };
File diff suppressed because it is too large Load Diff
+12 -6
View File
@@ -13,10 +13,13 @@ function compareVersions(a, b) {
return 0;
}
/** An update request still waiting: until this site runs the version that was asked for. */
function pendingUpdateRequest(version, requestedAt, requestedVersion) {
/**
* An update request still waiting: until this site runs the version that was asked for.
* `outdated`: a newer version than the one asked for is out, so the request can be changed to it.
*/
function pendingUpdateRequest(version, requestedAt, requestedVersion, latestVersion = null) {
if (!requestedAt || !requestedVersion || compareVersions(version, requestedVersion) >= 0) return null;
return { requestedAt, version: requestedVersion };
return { requestedAt, version: requestedVersion, outdated: !!latestVersion && compareVersions(latestVersion, requestedVersion) > 0 };
}
const str = (v, max) => String(v ?? '').slice(0, max);
@@ -87,7 +90,7 @@ async function statusPayload({ fresh = false } = {}) {
urgentUpdate,
updateSnoozed: updateAvailable && updateSnoozed({ latestVersion: s.latestVersion, snoozedVersion: s.updateSnoozedVersion, urgent: urgentUpdate }),
releases,
updateRequest: pendingUpdateRequest(APP_VERSION, s.updateRequestedAt, s.updateRequestedVersion),
updateRequest: pendingUpdateRequest(APP_VERSION, s.updateRequestedAt, s.updateRequestedVersion, s.latestVersion),
notices: activeNotices(s.notices),
};
}
@@ -139,8 +142,11 @@ const heartbeatNow = async (req, res) => {
const requestUpdate = async (req, res) => {
try {
const result = await hub.requestUpdate();
if (!result.alreadyRequested) {
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'hub_update_requested', targetType: 'hub', metadata: { from: APP_VERSION, to: result.version }, ip: getClientIp(req) });
if (!result.alreadyRequested || result.changed) {
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'hub_update_requested', targetType: 'hub',
metadata: { from: APP_VERSION, to: result.version, ...(result.changed ? { changedFrom: result.previousVersion || null } : {}) }, ip: getClientIp(req),
});
}
res.json(await statusPayload({ fresh: true }));
} catch (e) {
@@ -0,0 +1,178 @@
const prisma = require('../config/db');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const webhooks = require('../utils/webhooks');
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message });
const audit = (req, endpoint, action, metadata = {}) => logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'webhook_endpoint_changed', targetType: 'WebhookEndpoint', targetId: endpoint.id,
metadata: { action, name: endpoint.name, host: hostOf(endpoint.url), ...metadata }, ip: getClientIp(req),
});
function hostOf(url) {
try { return new URL(url).host; } catch { return null; }
}
/** What goes to an endpoint, in words, for its operator register entry. */
function dataShared(events) {
const parts = [];
if (events.some(e => e.startsWith('registration.') || e === 'ticket.checked_in' || e.startsWith('payment.') || e === 'user.created')) {
parts.push('names, email addresses and phone numbers of people who register or have an account');
}
if (events.some(e => e.startsWith('registration.'))) parts.push('what they registered for and its status');
if (events.some(e => e.startsWith('payment.'))) parts.push('payment and refund amounts');
if (events.includes('ticket.checked_in')) parts.push('check-in times');
if (events.includes('event.published')) parts.push('published event details');
return parts.length ? `${parts.join('; ')}.` : 'Nothing yet (no event types chosen).';
}
function cleanEvents(list) {
const events = [...new Set((Array.isArray(list) ? list : []).map(String))].filter(t => webhooks.TYPES.has(t));
if (!events.length) throw webhooks.webhookError('Choose at least one thing to send');
return events;
}
function cleanName(name) {
const n = String(name || '').trim();
if (!n) throw webhooks.webhookError('Give the webhook a name, e.g. the service it goes to');
return n.slice(0, 100);
}
const view = e => ({
id: e.id, name: e.name, url: e.url, events: e.events, isActive: e.isActive, operatorId: e.operatorId,
lastSuccessAt: e.lastSuccessAt, lastFailureAt: e.lastFailureAt, createdAt: e.createdAt,
});
// @desc Endpoints, the event types, and the last week's delivery counts per endpoint
// @route GET /api/outbound-webhooks
// @access Private/Admin
const list = async (req, res) => {
try {
const endpoints = await prisma.webhookEndpoint.findMany({ orderBy: { createdAt: 'asc' } });
const since = new Date(Date.now() - 7 * 864e5);
const counts = await prisma.webhookDelivery.groupBy({ by: ['endpointId', 'status'], where: { createdAt: { gte: since } }, _count: { _all: true } });
res.json({
eventTypes: webhooks.EVENT_TYPES,
keepDays: webhooks.KEEP_DAYS,
endpoints: endpoints.map(e => ({
...view(e),
lastWeek: Object.fromEntries(counts.filter(c => c.endpointId === e.id).map(c => [c.status, c._count._all])),
})),
});
} catch (error) { fail(res, error); }
};
// @desc Add an endpoint: the secret is in this answer only. Also added to the operator register.
// @route POST /api/outbound-webhooks { name, url, events }
// @access Private/Admin
const create = async (req, res) => {
try {
const name = cleanName(req.body?.name);
const url = webhooks.cleanUrl(req.body?.url);
const events = cleanEvents(req.body?.events);
const secret = webhooks.newSecret();
const u = new URL(url);
// POPIA: whoever receives this is an operator of the church's personal information.
const operator = await prisma.operator.create({
data: { name: `${name} (webhook)`, purpose: 'Receives notifications from this site through a webhook', dataShared: dataShared(events), website: u.origin },
}).catch(() => null);
const endpoint = await prisma.webhookEndpoint.create({ data: { name, url, events, secret: webhooks.encrypt(secret), operatorId: operator?.id || null } });
webhooks.forgetEndpoints();
audit(req, endpoint, 'created', { events });
res.status(201).json({ ...view(endpoint), secret });
} catch (error) { fail(res, error); }
};
// @route PUT /api/outbound-webhooks/:id { name?, url?, events?, isActive? }
// @access Private/Admin
const update = async (req, res) => {
try {
const current = await prisma.webhookEndpoint.findUnique({ where: { id: req.params.id } });
if (!current) return res.status(404).json({ message: 'Webhook not found' });
const data = {};
if (req.body?.name !== undefined) data.name = cleanName(req.body.name);
if (req.body?.url !== undefined) data.url = webhooks.cleanUrl(req.body.url);
if (req.body?.events !== undefined) data.events = cleanEvents(req.body.events);
if (req.body?.isActive !== undefined) data.isActive = req.body.isActive !== false;
const endpoint = await prisma.webhookEndpoint.update({ where: { id: current.id }, data });
if (current.operatorId && (data.events || data.url || data.name || data.isActive !== undefined)) {
await prisma.operator.update({
where: { id: current.operatorId },
data: { name: `${endpoint.name} (webhook)`, dataShared: dataShared(endpoint.events), website: new URL(endpoint.url).origin, active: endpoint.isActive },
}).catch(() => {});
}
webhooks.forgetEndpoints();
audit(req, endpoint, 'updated', { changedKeys: Object.keys(data) });
res.json(view(endpoint));
} catch (error) { fail(res, error); }
};
// @desc Delete an endpoint and its delivery log; its operator entry is marked inactive
// @route DELETE /api/outbound-webhooks/:id
// @access Private/Admin
const remove = async (req, res) => {
try {
const current = await prisma.webhookEndpoint.findUnique({ where: { id: req.params.id } });
if (!current) return res.status(404).json({ message: 'Webhook not found' });
await prisma.webhookEndpoint.delete({ where: { id: current.id } });
if (current.operatorId) await prisma.operator.update({ where: { id: current.operatorId }, data: { active: false } }).catch(() => {});
webhooks.forgetEndpoints();
audit(req, current, 'deleted');
res.json({ ok: true });
} catch (error) { fail(res, error); }
};
// @desc A new secret (the old one stops working at once); in this answer only
// @route POST /api/outbound-webhooks/:id/rotate-secret
// @access Private/Admin
const rotateSecret = async (req, res) => {
try {
const current = await prisma.webhookEndpoint.findUnique({ where: { id: req.params.id } });
if (!current) return res.status(404).json({ message: 'Webhook not found' });
const secret = webhooks.newSecret();
await prisma.webhookEndpoint.update({ where: { id: current.id }, data: { secret: webhooks.encrypt(secret) } });
audit(req, current, 'secret_rotated');
res.json({ secret });
} catch (error) { fail(res, error); }
};
// @desc Send a "webhook.test" now and say what came back
// @route POST /api/outbound-webhooks/:id/test
// @access Private/Admin
const test = async (req, res) => {
try {
if (!(await prisma.webhookEndpoint.findUnique({ where: { id: req.params.id }, select: { id: true } }))) return res.status(404).json({ message: 'Webhook not found' });
const d = await webhooks.sendTest(req.params.id);
res.json({ status: d?.status, statusCode: d?.lastStatusCode ?? null, error: d?.lastError || null });
} catch (error) { fail(res, error); }
};
// @desc An endpoint's deliveries, newest first (?status=failed to filter)
// @route GET /api/outbound-webhooks/:id/deliveries
// @access Private/Admin
const deliveries = async (req, res) => {
try {
const where = { endpointId: req.params.id, ...(req.query.status ? { status: String(req.query.status) } : {}) };
// "Load older": the next page is everything before the last one shown.
const before = req.query.before ? new Date(String(req.query.before)) : null;
if (before && !Number.isNaN(before.getTime())) where.createdAt = { lt: before };
const rows = await prisma.webhookDelivery.findMany({
where, orderBy: { createdAt: 'desc' }, take: Math.min(200, parseInt(req.query.limit, 10) || 50),
select: { id: true, eventId: true, type: true, status: true, attempts: true, nextAttemptAt: true, lastStatusCode: true, lastError: true, deliveredAt: true, createdAt: true, payload: true },
});
res.json(rows);
} catch (error) { fail(res, error); }
};
// @desc Send a delivery again now
// @route POST /api/outbound-webhooks/deliveries/:id/resend
// @access Private/Admin
const resend = async (req, res) => {
try {
const d = await webhooks.resend(req.params.id);
res.json({ status: d?.status, statusCode: d?.lastStatusCode ?? null, error: d?.lastError || null });
} catch (error) { fail(res, error); }
};
module.exports = { list, create, update, remove, rotateSecret, test, deliveries, resend, dataShared };
+22 -51
View File
@@ -1,12 +1,11 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { generateTicketsForRegistration, formsOutstanding } = require('../utils/ticketUtils');
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
const { computeDonationRemaining } = require('../utils/donationUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const payments = require('../payments');
const { createRegistrationCheckout } = require('../payments/checkouts');
const { emailTickets } = require('./ticketController');
const { safeErrorMessage } = require('../utils/errorUtils');
const { assertEventOpen, assertRegistrationEventOpen } = require('../utils/cashupUtils');
@@ -239,7 +238,9 @@ const createPayment = async (req, res) => {
// If it's a registration payment, update registration status
let updatedRegistration = null;
let generatedTickets = [];
// Attendee forms still holding the tickets back (the door offers them, or "Fill in later").
let formsNeeded = 0;
if (registrationId) {
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
@@ -285,6 +286,7 @@ const createPayment = async (req, res) => {
} catch (error) {
console.error('Error generating tickets:', error);
}
formsNeeded = await formsOutstanding(registrationId);
}
}
@@ -319,6 +321,7 @@ const createPayment = async (req, res) => {
...updatedRegistration
},
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined,
formsNeeded,
donationSplit: donationSplit || undefined
};
res.status(201).json(paymentWithUpdatedRegistration);
@@ -354,7 +357,15 @@ const getPayments = async (req, res) => {
// Campus-scoped supervisors: payments for events they can see (through the payment's event or
// its registration's), so unlinked donations aren't listed for them.
const scopeW = require('../utils/campusScope').eventWhere(await require('../utils/campusScope').scopeFor(req));
const where = Object.keys(scopeW).length ? { OR: [{ event: scopeW }, { registration: { event: scopeW } }] } : {};
const scopeWhere = Object.keys(scopeW).length ? { OR: [{ event: scopeW }, { registration: { event: scopeW } }] } : {};
// Narrowing filters: ?userId= (one person's), ?from= (on or after, e.g. today's start),
// ?donations=1 (donations and the legs that apply them to registrations).
const filters = [];
if (req.query.userId) filters.push({ userId: String(req.query.userId) });
const from = req.query.from ? new Date(String(req.query.from)) : null;
if (from && !Number.isNaN(from.getTime())) filters.push({ createdAt: { gte: from } });
if (['1', 'true'].includes(String(req.query.donations || ''))) filters.push({ OR: [{ isDonation: true }, { originalPaymentId: { not: null } }] });
const where = filters.length ? { AND: [scopeWhere, ...filters] } : scopeWhere;
const [payments, total] = await prisma.$transaction([
prisma.payment.findMany({ where, include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
prisma.payment.count({ where })
@@ -933,54 +944,14 @@ const createCheckout = async (req, res) => {
throw new Error('Either registrationId or eventId is required');
}
// A registration's balance
// A registration's balance (shared with POST /api/v2/registrations/:id/checkout)
if (registrationId) {
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true, eventId: true, event: { select: { endDate: true } } } });
if (!reg) { res.status(404); throw new Error('Registration not found'); }
if (reg.userId !== userId && req.user.role !== 'admin' && req.user.role !== 'supervisor') {
res.status(403); throw new Error('Not authorized to create checkout for this registration');
}
// If event is in the past, block self-service payment (mirrors the registration-edit block)
if (req.user.role !== 'admin' && req.user.role !== 'supervisor' && reg.event?.endDate && new Date(reg.event.endDate).getTime() < Date.now()) {
res.status(400); throw new Error('This event has already ended; payment can no longer be made for this registration');
}
await assertEventOpen(reg.eventId, res);
// Refresh early-bird pricing — updates priceSnapshot if any tier has expired or sold out.
// Must run before the balance is worked out so the checkout charges the right price.
const { changed: priceChanged } = await refreshPricingForRegistration(registrationId);
if (priceChanged) {
const freshReg = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true
}
});
const newTotal = computeRegistrationTotalDue(freshReg, new Date());
const totalPaid = (freshReg.payments || []).reduce((s, p) => s + p.amount, 0);
return res.status(200).json({
priceUpdated: true,
newTotal: Math.max(0, newTotal - totalPaid),
message: 'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.'
});
}
// Part of the balance (supervisor payments page)
if (amount && !isNaN(parseFloat(amount))) {
const result = await createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, amount: parseFloat(amount), provider });
return res.status(200).json(result);
}
// Optional donation added at registration ("Donation amount" on the register form)
let donation = 0;
if (donationAmount !== undefined && donationAmount !== null && donationAmount !== '') {
donation = Math.round(parseFloat(donationAmount) * 100) / 100;
if (!(donation >= 0) || donation > 100000) { res.status(400); throw new Error('Invalid donation amount'); }
if (donation > 0 && donation < payments.MIN_ONLINE_AMOUNT) { res.status(400); throw new Error(`Minimum donation is R${payments.MIN_ONLINE_AMOUNT}`); }
}
const result = await createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount: donation, provider });
const { startRegistrationCheckout } = require('../services/payments');
const result = await startRegistrationCheckout(registrationId, req.user, {
amount: amount && !isNaN(parseFloat(amount)) ? parseFloat(amount) : null,
donationAmount: donationAmount === undefined || donationAmount === null || donationAmount === '' ? null : parseFloat(donationAmount),
successUrl, cancelUrl, failureUrl, provider,
});
return res.status(200).json(result);
}
@@ -65,12 +65,30 @@ const getNoticeDraft = async (req, res) => {
placeholders: notice.PLACEHOLDERS,
versions: await versionList(),
builtIn: notice.defaultSections(),
// Changes to the built-in text since this site last reviewed them (only once it publishes its own).
builtInUpdates: await notice.builtInUpdates(),
});
} catch (error) {
fail(res, error);
}
};
// @desc The built-in text changes have been gone through (the editor stops listing them)
// @route POST /api/privacy/notice/builtin-reviewed
// @access Private/Admin
const markBuiltInReviewed = async (req, res) => {
try {
await notice.markBuiltInReviewed();
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'settings_updated', targetType: 'privacy_notice',
metadata: { builtInReviewed: notice.BUILT_IN_REVISION }, ip: getClientIp(req),
});
res.json({ builtInUpdates: [] });
} catch (error) {
fail(res, error);
}
};
// @desc Save the draft (body.sections)
// @route PUT /api/privacy/notice/draft
// @access Private/Admin
@@ -425,6 +443,7 @@ const updateBreach = async (req, res) => {
};
module.exports = {
markBuiltInReviewed,
getReadiness,
getRetention,
saveRetention,
+11 -5
View File
@@ -227,12 +227,18 @@ const listRequests = async (req, res) => {
const where = { selfService: false };
if (status === 'open') where.status = 'open';
else if (status === 'closed') where.status = { not: 'open' };
const rows = await prisma.dataRequest.findMany({
const q = String(req.query.search || '').trim().slice(0, 100);
if (q) where.OR = [{ name: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }];
// Closed requests pile up over the years, so they come a page at a time (newest first).
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const [rows, total] = await Promise.all([prisma.dataRequest.findMany({
where,
orderBy: status === 'open' ? { dueAt: 'asc' } : { createdAt: 'desc' },
take: 200,
take: limit,
skip: (page - 1) * limit,
include: { handledBy: { select: { name: true } }, user: { select: { isActive: true, phoneNumber: true } } },
});
}), prisma.dataRequest.count({ where })]);
const requests = await Promise.all(rows.map(async r => ({
id: r.id, type: r.type, status: r.status, name: r.name, email: r.email, details: r.details, response: r.response,
dueAt: r.dueAt, closedAt: r.closedAt, createdAt: r.createdAt, userId: r.userId,
@@ -240,8 +246,8 @@ const listRequests = async (req, res) => {
overdue: r.status === 'open' && r.dueAt < new Date(),
blockers: r.type === 'deletion' && r.status === 'open' && r.userId ? await privacy.deletionBlockers(r.userId) : [],
})));
const openCount = status === 'open' ? requests.length : await prisma.dataRequest.count({ where: { selfService: false, status: 'open' } });
res.json({ requests, openCount });
const openCount = status === 'open' && !q ? total : await prisma.dataRequest.count({ where: { selfService: false, status: 'open' } });
res.json({ requests, openCount, total, page, limit, pages: Math.max(1, Math.ceil(total / limit)) });
} catch (error) {
fail(res, error);
}
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,5 +1,6 @@
const PDFDocument = require('pdfkit');
const ExcelJS = require('exceljs');
// ExcelTS: a maintained TypeScript port of exceljs (same API, no dependencies).
const ExcelJS = require('@cj-tech-master/excelts');
const fs = require('fs');
const path = require('path');
const { sendMail, emailWrapper } = require('../utils/email');
@@ -0,0 +1,24 @@
// Season passes (v1). The logic is in services/series.ts, shared with /api/v2 (1.51.9).
const series = require('../services/series');
const { getClientIp } = require('../utils/requestUtils');
const { scopeFor } = require('../utils/campusScope');
const ctxOf = async (req) => ({ scope: await scopeFor(req), ip: getClientIp(req) });
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message, ...(error.code && !/^P\d/.test(error.code) ? { code: error.code } : {}) });
// @route GET /api/season-passes?seriesId=
const listPasses = async (req, res) => { try { res.json(await series.listPasses(req.query.seriesId ? String(req.query.seriesId) : undefined)); } catch (e) { fail(res, e); } };
// @route POST /api/season-passes
const createPass = async (req, res) => { try { res.status(201).json(await series.createPass(req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route PUT /api/season-passes/:id (events and price fixed once someone has bought it)
const updatePass = async (req, res) => { try { res.json(await series.updatePass(req.params.id, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route DELETE /api/season-passes/:id
const deletePass = async (req, res) => { try { await series.deletePass(req.params.id, req.user, await ctxOf(req)); res.json({ ok: true }); } catch (e) { fail(res, e); } };
// @route POST /api/season-passes/:id/buy { quantity, provider? }
const buy = async (req, res) => { try { res.status(201).json(await series.buyPass(req.params.id, req.user.id, { quantity: req.body?.quantity ?? 1, provider: req.body?.provider || null })); } catch (e) { fail(res, e); } };
// @route POST /api/season-passes/purchases/:id/checkout
const payPurchase = async (req, res) => { try { res.json(await series.payPassPurchase(req.params.id, req.user.id, { provider: req.body?.provider || null })); } catch (e) { fail(res, e); } };
// @route GET /api/season-passes/mine
const myPurchases = async (req, res) => { try { res.json(await series.myPassPurchases(req.user.id)); } catch (e) { fail(res, e); } };
module.exports = { listPasses, createPass, updatePass, deletePass, buy, payPurchase, myPurchases };
+20 -164
View File
@@ -1,189 +1,45 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
// Scanning sections: which ticket types may come in at which door. Shared with
// /api/v2/sections (services/setup.ts, 1.51.8).
const setup = require('../services/setup');
const { listWhere } = require('../utils/campusScope');
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message });
//
// @desc Get sections (optionally by event)
// @route GET /api/sections?eventId=xxx
// @access Private/Staff
//
const getSections = async (req, res) => {
try {
const { eventId } = req.query;
const { withScope, listWhere } = require('../utils/campusScope');
const where = withScope(eventId && eventId !== 'all' ? { eventId } : {}, await listWhere(req, 'event'));
const sections = await prisma.section.findMany({
where,
include: {
allowedOptions: {
include: {
eventOption: true
}
},
event: {
select: { id: true, title: true }
}
},
orderBy: { name: 'asc' }
});
res.json(sections);
} catch (error) {
res.status(400).json({ message: error.message });
}
try {
res.json(await setup.listSections(req.query.eventId, await listWhere(req, 'event')));
} catch (error) { fail(res, error); }
};
//
// @desc Create section
// @route POST /api/sections
// @access Private/Admin/Supervisor
//
const createSection = async (req, res) => {
try {
const { eventId, name, allowedOptionIds } = req.body;
if (!eventId || !name) {
res.status(400);
throw new Error('Event and section name are required');
}
const event = await prisma.event.findUnique({
where: { id: eventId }
});
if (!event) {
res.status(404);
throw new Error('Event not found');
}
const section = await prisma.section.create({
data: {
id: uuidv4(),
eventId,
name,
updatedAt: new Date()
}
});
// Attach allowed options if provided
if (Array.isArray(allowedOptionIds) && allowedOptionIds.length > 0) {
await prisma.sectionOption.createMany({
data: allowedOptionIds.map(optionId => ({
id: uuidv4(),
sectionId: section.id,
eventOptionId: optionId
})),
skipDuplicates: true
});
}
const fullSection = await prisma.section.findUnique({
where: { id: section.id },
include: {
allowedOptions: {
include: { eventOption: true }
}
}
});
res.status(201).json(fullSection);
} catch (error) {
res.status(400).json({ message: error.message });
}
try {
res.status(201).json(await setup.createSection(req.body || {}));
} catch (error) { fail(res, error); }
};
//
// @desc Update section
// @route PUT /api/sections/:id
// @access Private/Admin/Supervisor
//
const updateSection = async (req, res) => {
try {
const { name, allowedOptionIds } = req.body;
const section = await prisma.section.findUnique({
where: { id: req.params.id }
});
if (!section) {
res.status(404);
throw new Error('Section not found');
}
const updatedSection = await prisma.section.update({
where: { id: req.params.id },
data: {
name: name || section.name,
updatedAt: new Date()
}
});
// If allowed options supplied → reset them
if (Array.isArray(allowedOptionIds)) {
await prisma.sectionOption.deleteMany({
where: { sectionId: section.id }
});
if (allowedOptionIds.length > 0) {
await prisma.sectionOption.createMany({
data: allowedOptionIds.map(optionId => ({
id: uuidv4(),
sectionId: section.id,
eventOptionId: optionId
}))
});
}
}
const fullSection = await prisma.section.findUnique({
where: { id: section.id },
include: {
allowedOptions: {
include: { eventOption: true }
}
}
});
res.json(fullSection);
} catch (error) {
res.status(400).json({ message: error.message });
}
try {
res.json(await setup.updateSection(req.params.id, req.body || {}));
} catch (error) { fail(res, error); }
};
//
// @desc Delete section
// @route DELETE /api/sections/:id
// @access Private/Admin
//
const deleteSection = async (req, res) => {
try {
const section = await prisma.section.findUnique({
where: { id: req.params.id }
});
if (!section) {
res.status(404);
throw new Error('Section not found');
}
await prisma.section.delete({
where: { id: section.id }
});
res.json({ message: 'Section deleted' });
} catch (error) {
res.status(400).json({ message: error.message });
}
try {
await setup.deleteSection(req.params.id);
res.json({ message: 'Section deleted' });
} catch (error) { fail(res, error); }
};
module.exports = {
getSections,
createSection,
updateSection,
deleteSection
};
module.exports = { getSections, createSection, updateSection, deleteSection };
@@ -0,0 +1,25 @@
// Event series (v1). The logic is in services/series.ts, shared with /api/v2 (1.51.9).
const series = require('../services/series');
const { getClientIp } = require('../utils/requestUtils');
const { scopeFor } = require('../utils/campusScope');
const ctxOf = async (req) => ({ scope: await scopeFor(req), ip: getClientIp(req) });
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message, ...(error.code && !/^P\d/.test(error.code) ? { code: error.code } : {}) });
// @route GET /api/series — every series, its events (the ones this person may see) and followers
const listSeries = async (req, res) => { try { res.json(await series.listSeries(await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route POST /api/series
const createSeries = async (req, res) => { try { res.status(201).json(await series.createSeries(req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route PUT /api/series/:id
const updateSeries = async (req, res) => { try { res.json(await series.updateSeries(req.params.id, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route DELETE /api/series/:id (events stay, without a series; followers are dropped)
const deleteSeries = async (req, res) => { try { await series.deleteSeries(req.params.id, req.user, await ctxOf(req)); res.json({ ok: true }); } catch (e) { fail(res, e); } };
// @route GET /api/series/by-slug/:slug — the public page
const getSeriesBySlug = async (req, res) => { try { res.json(await series.seriesPage(req.params.slug, req.user?.id || null)); } catch (e) { fail(res, e); } };
// @route GET /api/series/:id/compare — year on year
const compare = async (req, res) => { try { res.json(await series.compareSeries(req.params.id, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route POST|DELETE /api/series/:id/follow
const followSeries = async (req, res) => { try { res.json(await series.setFollowing(req.params.id, req.user.id, true)); } catch (e) { fail(res, e); } };
const unfollowSeries = async (req, res) => { try { res.json(await series.setFollowing(req.params.id, req.user.id, false)); } catch (e) { fail(res, e); } };
module.exports = { listSeries, createSeries, updateSeries, deleteSeries, getSeriesBySlug, followSeries, unfollowSeries, compare };
+18 -273
View File
@@ -1,8 +1,9 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { assertRegistrationEventOpen } = require('../utils/cashupUtils');
// Scanning, shared with /api/v2/checkin (TypeScript, 1.51.2).
const checkin = require('../services/checkin');
// @desc Generate tickets for a registration
// @route POST /api/tickets/generate
@@ -256,99 +257,11 @@ const getTicketByQrCode = async (req, res) => {
// @access Private/Staff
const getScanPreview = async (req, res) => {
try {
// Single JOIN query instead of 6 sequential Prisma round-trips (critical for remote DBs)
const rows = await prisma.$queryRaw`
SELECT
t.id,
t."qrCode",
t.quantity,
t."isUsed",
t."eventId",
ro.id AS "registrationOptionId",
ro."sessionId" AS "pinnedSessionId",
ro."sessionIds" AS "chosenSessionIds",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
ov.id AS "variantId",
ov.name AS "variantName",
e.id AS "evId",
e.title AS "eventTitle",
e."scheduleType" AS "scheduleType",
u.id AS "userId",
u.name AS "userName",
a.name AS "attendeeName",
COALESCE(
json_agg(
json_build_object(
'quantityRedeemed', tu."quantityRedeemed",
'scannedAt', tu."scannedAt",
'sessionId', tu."sessionId"
)
) FILTER (WHERE tu.id IS NOT NULL),
'[]'::json
) AS usages
FROM "Ticket" t
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
LEFT JOIN "OptionVariant" ov ON ov.id = ro."variantId"
LEFT JOIN "Event" e ON e.id = t."eventId"
LEFT JOIN "User" u ON u.id = t."userId"
LEFT JOIN "Attendee" a ON a.id = t."attendeeId"
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
WHERE t."qrCode" = ${req.params.qrCode}
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id, a.id
`;
if (!rows || rows.length === 0) {
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
const transfer = await findTransferByOldCode(req.params.qrCode);
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
res.status(404);
throw new Error('Ticket not found');
}
const r = rows[0];
const { consentStatusForTickets } = require('../utils/consent');
const consentStatus = (await consentStatusForTickets([r.id])).get(r.id) || 'not_required';
const usages = r.usages || [];
// Multi-day events: check-in is per session, so what's left is counted for the
// session this scan is for (the one picked, or today's).
let remaining = Number(r.quantity) - usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
let session = null;
let sessionError = null;
if (r.scheduleType === 'sessions') {
const sessionsUtil = require('../utils/sessions');
const schedule = await sessionsUtil.eventSchedule(r.eventId);
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, req.query.sessionId || null, new Date(), r.chosenSessionIds);
sessionError = found.error;
if (found.session) {
session = { id: found.session.id, label: sessionsUtil.sessionLabel(found.session, schedule.sessions) };
remaining = Number(r.quantity) - sessionsUtil.redeemedForSession(usages, found.session.id);
} else {
remaining = 0;
}
}
res.json({
id: r.id,
quantity: Number(r.quantity),
isUsed: r.isUsed,
eventId: r.eventId,
consentStatus,
remaining,
session,
sessionError,
registrationOption: {
id: r.registrationOptionId,
eventOption: { id: r.eventOptionId, name: r.eventOptionName },
variant: r.variantId ? { id: r.variantId, name: r.variantName } : null,
},
event: { id: r.evId, title: r.eventTitle },
user: { id: r.userId, name: r.userName },
attendee: r.attendeeName ? { name: r.attendeeName } : null,
usages
});
const result = await checkin.previewScan(req.params.qrCode, { sessionId: req.query.sessionId || null });
if (result.outcome === 'transferred') return res.status(410).json({ message: result.message, code: 'TICKET_TRANSFERRED' });
res.json(result.ticket);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
}
};
@@ -357,187 +270,19 @@ const getScanPreview = async (req, res) => {
// @access Private/Staff
const scanTicket = async (req, res) => {
try {
// Single JOIN query — avoids multiple sequential round-trips to the remote DB
const rows = await prisma.$queryRaw`
SELECT
t.id,
t.quantity,
t."isUsed",
t."eventId",
e.title AS "eventTitle",
e."scheduleType" AS "scheduleType",
ro."sessionId" AS "pinnedSessionId",
ro."sessionIds" AS "chosenSessionIds",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
eo."isMainTicket" AS "isMainTicket",
COALESCE(
json_agg(
json_build_object(
'id', tu.id,
'quantityRedeemed', tu."quantityRedeemed",
'scannedAt', tu."scannedAt",
'sessionId', tu."sessionId"
)
) FILTER (WHERE tu.id IS NOT NULL),
'[]'::json
) AS usages
FROM "Ticket" t
LEFT JOIN "Event" e ON e.id = t."eventId"
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
WHERE t."qrCode" = ${req.params.qrCode}
GROUP BY t.id, e.id, eo.id, ro.id
`;
if (!rows || rows.length === 0) {
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
const transfer = await findTransferByOldCode(req.params.qrCode);
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
res.status(404);
throw new Error('Ticket not found');
}
const r = rows[0];
const ticket = {
id: r.id,
quantity: Number(r.quantity),
isUsed: r.isUsed,
eventId: r.eventId,
event: { title: r.eventTitle },
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName, isMainTicket: r.isMainTicket } },
usages: r.usages || []
};
// Optional server-side guard: ensure ticket belongs to the requested event when provided
const providedEventId = String((req.query?.eventId || req.body?.eventId) || '').trim();
if (providedEventId && ticket.eventId !== providedEventId) {
return res.status(400).json({ message: 'Ticket belongs to a different event', ticket });
}
// Section checks used to happen only in the browser; enforce them here too.
const sectionId = String((req.query?.sectionId || req.body?.sectionId) || '').trim() || null;
if (sectionId) {
const section = await prisma.section.findUnique({ where: { id: sectionId }, include: { allowedOptions: true } });
if (!section || section.eventId !== ticket.eventId) {
return res.status(400).json({ message: 'Section not found for this event', ticket });
}
if (!section.allowedOptions.some(o => o.eventOptionId === r.eventOptionId)) {
return res.status(400).json({ message: 'This ticket is not valid for this section', ticket });
}
}
// A section (or the whole event) locked to another device for offline scanning
// can't be scanned online — those scans would be invisible to the offline device.
const { blockingLock } = require('../utils/scanLocks');
const lock = await blockingLock({ eventId: ticket.eventId, optionId: r.eventOptionId }, { sectionId, deviceId: req.body?.deviceId || null });
if (lock) {
const scope = lock.section ? `"${lock.section.name}"` : 'This event';
return res.status(423).json({ message: `${scope} is offline-locked by ${lock.user?.name || 'another device'}${lock.deviceName ? ` on ${lock.deviceName}` : ''}. Scan it on that device, or ask a supervisor to release the lock.`, code: 'SCAN_LOCKED' });
}
// Multi-day events: the check-in is for one session (the one picked, or today's), and
// what's left is counted per session.
let session = null;
let sessionsUtil = null;
let schedule = null;
if (r.scheduleType === 'sessions') {
sessionsUtil = require('../utils/sessions');
schedule = await sessionsUtil.eventSchedule(ticket.eventId);
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, String(req.body?.sessionId || req.query?.sessionId || '').trim() || null, new Date(), r.chosenSessionIds);
if (!found.session) return res.status(400).json({ message: found.error, code: 'NO_SESSION', ticket });
session = found.session;
}
// Compute how many have already been redeemed
const totalRedeemed = session
? sessionsUtil.redeemedForSession(ticket.usages, session.id)
: (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const remaining = (ticket.quantity || 1) - totalRedeemed;
if (remaining <= 0) {
if (session) {
const last = (ticket.usages || []).filter(u => u.sessionId === session.id).sort((a, b) => new Date(b.scannedAt) - new Date(a.scannedAt))[0];
const at = last ? new Date(last.scannedAt).toLocaleTimeString('en-ZA', { timeZone: sessionsUtil.TIMEZONE, hour: '2-digit', minute: '2-digit' }) : '';
return res.status(403).json({
message: `Already checked in for ${sessionsUtil.sessionLabel(session, schedule.sessions)}${at ? ` at ${at}` : ''}`,
ticket,
remaining: 0
});
}
return res.status(403).json({
message: 'Ticket has already been fully used',
ticket,
remaining: 0
});
}
// Youth events: no check-in without parental consent, unless a supervisor overrides.
const { consentStatusForTickets } = require('../utils/consent');
const consentStatus = (await consentStatusForTickets([ticket.id])).get(ticket.id) || 'not_required';
if (!['not_required', 'given'].includes(consentStatus)) {
const canOverride = ['admin', 'supervisor'].includes(req.user?.role);
if (!(req.body?.overrideConsent === true && canOverride)) {
const why = consentStatus === 'declined' ? 'Parental consent was declined' : consentStatus === 'pending' ? 'Parental consent is still pending' : 'Parental consent is missing';
return res.status(403).json({ message: `${why} — this ticket can't be checked in.`, code: 'CONSENT_REQUIRED', consentStatus, canOverride, ticket });
}
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'consent_override',
targetType: 'Ticket', targetId: ticket.id,
metadata: { consentStatus, eventId: ticket.eventId },
ip: getClientIp(req),
});
}
// Determine how many to redeem this scan (defaults to all remaining)
const requestedQty = parseInt(req.body?.qty || req.body?.quantity) || remaining;
const qtyToRedeem = Math.min(Math.max(1, requestedQty), remaining);
const newTotalRedeemed = totalRedeemed + qtyToRedeem;
const newRemaining = (ticket.quantity || 1) - newTotalRedeemed;
const fullyUsed = newRemaining <= 0;
// Run the usage insert and ticket status update in parallel — they don't depend on each other
const [ticketUsage] = await Promise.all([
prisma.ticketUsage.create({
data: {
id: uuidv4(),
ticketId: ticket.id,
scannedById: req.user.id,
quantityRedeemed: qtyToRedeem,
sessionId: session ? session.id : null,
}
}),
// A multi-session ticket is never "used up" — it's checked in again next session.
prisma.ticket.update({
where: { id: ticket.id },
data: { isUsed: session ? false : fullyUsed, updatedAt: new Date() }
})
]);
// Only Main Tickets trigger a check-in notification (add-on tickets scanned via
// the QR camera flow are unaffected)
if (ticket.registrationOption?.eventOption?.isMainTicket) {
require('../utils/notifications')
.sendCheckInEmails(ticket.id, qtyToRedeem, newTotalRedeemed, newRemaining)
.catch(e => console.error('Failed to send check-in notification:', e));
}
const sessionPart = session ? ` for ${sessionsUtil.sessionLabel(session, schedule.sessions)}` : '';
res.json({
message: `Ticket scanned successfully${sessionPart} (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
session: session ? { id: session.id, label: sessionsUtil.sessionLabel(session, schedule.sessions) } : null,
ticketUsage,
ticket,
qtyRedeemed: qtyToRedeem,
totalRedeemed: newTotalRedeemed,
remaining: newRemaining,
});
const { getClientIp } = require('../utils/requestUtils');
const result = await checkin.scanTicket(req.params.qrCode, req.user, {
eventId: req.query?.eventId || req.body?.eventId || null,
sectionId: req.query?.sectionId || req.body?.sectionId || null,
sessionId: req.body?.sessionId || req.query?.sessionId || null,
deviceId: req.body?.deviceId || null,
qty: parseInt(req.body?.qty || req.body?.quantity) || null,
overrideConsent: req.body?.overrideConsent === true,
}, getClientIp(req));
const { status, body } = checkin.v1ScanResponse(result);
res.status(status).json(body);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
}
};
+66 -496
View File
@@ -3,28 +3,22 @@ const { generateToken, hashPassword, comparePassword } = require('../config/auth
const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents');
const { getSettingSync } = require('../utils/settingsCache');
const {
METHODS: TWO_FACTOR_METHODS,
availableMethods,
createChallenge,
verifyChallenge,
resendChallenge,
issueTrustedDevice,
isTrustedDevice,
clearTrustedDevices,
requiresTwoFactor,
enforcementMethod,
} = require('../utils/twoFactor');
const axios = require('axios');
// ─── Helpers ────────────────────────────────────────────────────────────────
// Org name for email subjects — falls back to Cross Code (the platform vendor)
// rather than any specific customer's name when unconfigured.
function getOrgName() {
return getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
}
// Sign up, log in and profile changes are shared with /api/v2 (TypeScript, 1.51.2).
const accounts = require('../services/auth');
const { getOrgName, sendActivationLink, sendWelcomeEmail } = accounts;
const { v1StatusOf } = require('../services/errors');
// Resolve a client IP from the request (works behind proxies)
function getClientIp(req) {
@@ -33,145 +27,10 @@ function getClientIp(req) {
return req.socket?.remoteAddress || 'unknown';
}
const PRIVATE_IP_RE = /^(::1|::ffff:127\.|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/;
// Fire-and-forget: create a 24h activation token and deliver it to an inactive
// account — via email if it has a real (non-guest) address, otherwise via
// WhatsApp if it has a phone number. Used both when a login attempt hits an
// inactive account, and immediately when an admin/supervisor creates an
// account on someone's behalf (walk-in / manual registration).
async function sendActivationLink(user) {
const hasRealEmail = !!(user?.email && !user.email.endsWith('@guest.local'));
if (!hasRealEmail && !user?.phoneNumber) return;
try {
const token = uuidv4();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
await prisma.passwordReset.updateMany({
where: { userId: user.id, used: false },
data: { used: true }
});
await prisma.passwordReset.create({
data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false }
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
if (hasRealEmail) {
const { sendMail, buildAccountActivationEmail } = require('../utils/email');
const content = buildAccountActivationEmail({ name: user.name, activationUrl });
sendMail({ to: user.email, subject: `Activate your ${getOrgName()} account`, ...content })
.catch(e => console.warn('[activation email] Failed:', e?.message || e));
} else {
const orgName = getOrgName();
const waMessage = [
`🔓 *Activate your ${orgName} account*`,
'',
`Hi ${user.name || 'there'},`,
'',
`Your account needs to be activated before you can log in. Tap the link below to set a password and activate your account:`,
'',
activationUrl,
'',
`_This link expires in 24 hours._`,
].join('\n');
const { waTextAny } = require('../utils/notify');
waTextAny(user, waMessage).catch(e => console.warn('[activation WA] Failed:', e?.message || e));
}
} catch (e) {
console.warn('[activation token] Failed to create activation token:', e?.message || e);
}
}
// Fire-and-forget: send a login notification email with approximate geo location
async function sendLoginNotification(user, req) {
try {
const ip = getClientIp(req);
const userAgent = req.headers['user-agent'] || 'Unknown device';
const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' });
let location = 'Unknown location';
if (ip !== 'unknown' && !PRIVATE_IP_RE.test(ip)) {
try {
const geo = await axios.get(
`http://ip-api.com/json/${ip}?fields=status,city,regionName,country`,
{ timeout: 3000 }
);
if (geo.data?.status === 'success') {
const parts = [geo.data.city, geo.data.regionName, geo.data.country].filter(Boolean);
if (parts.length) location = parts.join(', ');
}
} catch { /* geo lookup failure is non-fatal */ }
}
const { sendMail, buildLoginNotificationEmail } = require('../utils/email');
const { buildWALogin } = require('../utils/waMessages');
const content = buildLoginNotificationEmail({ name: user.name, when, location, userAgent });
// Security: always email; also WhatsApp if preferred
await sendMail({ to: user.email, subject: `New login to your ${getOrgName()} account`, ...content });
const { waText } = require('../utils/notify');
await waText(user, buildWALogin({ name: user.name, when, location, userAgent })).catch(() => {});
} catch (e) {
console.warn('[login notification] Failed:', e?.message || e);
}
}
// Fire-and-forget: send welcome email with next upcoming events
async function sendWelcomeEmail(user) {
try {
const events = await prisma.event.findMany({
where: { isActive: true, startDate: { gt: new Date() } },
orderBy: { startDate: 'asc' },
take: 3,
select: { title: true, startDate: true },
});
const { sendMail, buildWelcomeEmail } = require('../utils/email');
const { buildWAWelcome } = require('../utils/waMessages');
const content = buildWelcomeEmail({ name: user.name, events });
const { shouldEmail, waText } = require('../utils/notify');
// Welcome is always sent via email; also via WhatsApp if preferred
await sendMail({ to: user.email, subject: `Welcome to ${getOrgName()}!`, ...content });
await waText(user, buildWAWelcome({ name: user.name, events })).catch(() => {});
} catch (e) {
console.warn('[welcome email] Failed:', e?.message || e);
}
}
// Fields the login flow needs once the password (and any 2FA code) has checked out.
const LOGIN_USER_SELECT = {
id: true, name: true, email: true, role: true, tokenVersion: true, isActive: true,
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true, newsAskedAt: true,
noticeVersionAccepted: true,
};
// Errors from utils/twoFactor carry their own HTTP status; everything else follows
// the controller convention of res.status(...) before throwing.
// Errors from utils/twoFactor and the services carry their own HTTP status; everything else
// follows the controller convention of res.status(...) before throwing.
function errorStatus(error, res) {
return error?.statusCode || (res.statusCode === 200 ? 400 : res.statusCode);
}
// Final step of a successful login (after the password, and the 2FA code when
// required): notify, record the activity, and issue the session token.
async function completeLogin(user, req, res, extra = {}) {
const noticePromptPending = await require('../utils/privacyNotice').noticePromptPending(user).catch(() => false);
// Retention: logging in keeps the account (and cancels an inactivity warning).
prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date(), inactivityWarnedAt: null } }).catch(() => {});
// Send login notification in the background
sendLoginNotification(user, req).catch(() => {});
logSecurityEvent({ userId: user.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
res.json({
id: user.id,
name: user.name,
email: user.email,
role: user.role,
token: generateToken(user.id, user.role, user.tokenVersion),
// POPIA: ask about News & announcements once (see NewsPrompt on the frontend), and to
// accept a privacy notice version marked as a significant change (NoticePrompt).
newsPromptPending: !user.newsAskedAt,
noticePromptPending,
...extra
});
return v1StatusOf(error, res.statusCode === 200 ? 400 : res.statusCode);
}
// @desc Register a new user
@@ -179,79 +38,9 @@ async function completeLogin(user, req, res, extra = {}) {
// @access Public
const registerUser = async (req, res) => {
try {
const { name, email, password, phoneNumber, notificationPreference, newsOptIn } = req.body;
// Normalize phone using SA-aware normalisation
const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp');
const phone = normalizeZAPhone(phoneNumber) || (phoneNumber ? phoneNumber.replace(/\D/g, '') || null : null);
// Validate preference — WhatsApp requires a valid phone number
const allowedPrefs = ['email', 'whatsapp', 'both'];
let pref = allowedPrefs.includes(notificationPreference) ? notificationPreference : 'email';
if ((pref === 'whatsapp' || pref === 'both') && !isValidZAPhone(phoneNumber)) {
pref = 'email'; // silently fall back if no valid number
}
// Check if user already exists
const userExists = await prisma.user.findFirst({
where: {
OR: [
{ email },
...(phone ? [{ phoneNumber: phone }] : [])
]
}
});
if (userExists) {
res.status(400);
throw new Error('User already exists');
}
// Hash password
const hashedPassword = await hashPassword(password);
// Create user
const user = await prisma.user.create({
data: {
id: uuidv4(),
name,
email,
password: hashedPassword,
phoneNumber: phone,
notificationPreference: pref,
updatedAt: new Date()
}
});
if (user) {
// POPIA: the sign-up form's (unticked) "news and announcements" box. Recorded either
// way, so they aren't asked again at their first login.
await require('../utils/privacy').setNewsPreferences(
user.id,
{ email: newsOptIn === true, whatsapp: newsOptIn === true },
{ source: 'signup', ip: getClientIp(req), answered: true, logAll: true },
).catch(e => console.warn('[signup] news preference not saved:', e?.message || e));
// The sign-up form requires ticking "I agree to the Terms of Use and Privacy Policy".
await require('../utils/privacyNotice').acceptNotice(user.id, { source: 'signup', ip: getClientIp(req) })
.catch(e => console.warn('[signup] notice acceptance not saved:', e?.message || e));
// Send welcome email in the background — don't block the response
sendWelcomeEmail(user).catch(() => {});
res.status(201).json({
id: user.id,
name: user.name,
email: user.email,
role: user.role,
token: generateToken(user.id, user.role, user.tokenVersion)
});
} else {
res.status(400);
throw new Error('Invalid user data');
}
res.status(201).json(await accounts.signUp(req.body || {}, { ip: getClientIp(req), userAgent: req.headers['user-agent'] }));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
}
};
@@ -260,108 +49,12 @@ const registerUser = async (req, res) => {
// @access Public
const loginUser = async (req, res) => {
try {
const {email, password, deviceToken} = req.body;
const rawInput = email;
// Normalize phone input — convert SA local format (0xx) → international (27xx)
const { normalizeZAPhone } = require('../utils/whatsapp');
const normalizedPhone = normalizeZAPhone(rawInput) || rawInput.replace(/\D/g, '');
// Check for user email or phone
const user = await prisma.user.findFirst({
where: {
OR: [
{ email: rawInput },
...(normalizedPhone ? [{ phoneNumber: normalizedPhone }] : []),
]
}
});
if (!user) {
res.status(401);
throw new Error('User does not exist');
const { email, password, deviceToken } = req.body || {};
const result = await accounts.logIn({ identifier: email, password, deviceToken }, { ip: getClientIp(req), userAgent: req.headers['user-agent'] });
if (result.status === 'code_required') {
return res.json({ twoFactorRequired: true, ...(result.enrolling ? { enrolling: true } : {}), ...result.challenge });
}
// Check if user is active
if (!user.isActive) {
// Resend the activation link on each failed login attempt against an inactive
// account, in case the original one (sent at creation, or a prior attempt) expired.
await sendActivationLink(user);
// If the account has a real email (not a guest placeholder), it went out via email
if (user.email && !user.email.endsWith('@guest.local')) {
res.status(401);
throw new Error('Your account is not yet active. We\'ve sent you an email with a link to activate your account.');
}
// No real email — if they have a phone number, it went out via WhatsApp
if (user.phoneNumber) {
res.status(401);
throw new Error('Your account is not yet active. We\'ve sent you a WhatsApp message with a link to activate your account.');
}
res.status(401);
throw new Error('Your account has been deactivated');
}
const MAX_ATTEMPTS = 5;
const LOCK_TIME = 5 * 60 * 1000; // 5 min
// Check lock
if (user.lockUntil && user.lockUntil > new Date()) {
const now = new Date().getTime();
const lockTime = new Date(user.lockUntil).getTime();
const diffMs = lockTime - now;
const diffMinutes = Math.ceil(diffMs / (1000 * 60));
throw new Error(`Too many attempts. Try again in ${diffMinutes} minute(s).`);
}
// Check password
const isMatch = await comparePassword(password, user.password);
if (!isMatch) {
const attempts = user.failedAttempts + 1;
await prisma.user.update({
where: { id: user.id },
data: {
failedAttempts: attempts,
lockUntil:
attempts >= MAX_ATTEMPTS
? new Date(Date.now() + LOCK_TIME)
: null,
},
});
throw new Error("Invalid email or password");
}
// ✅ SUCCESS → reset attempts
const updated = await prisma.user.update({
where: { id: user.id },
data: { failedAttempts: 0, lockUntil: null },
select: LOGIN_USER_SELECT,
});
// Two-factor: the password alone isn't enough unless this browser was
// remembered earlier. Send a code and hand back a challenge instead of a token.
if (updated.twoFactorEnabled && !(await isTrustedDevice(updated.id, deviceToken))) {
const challenge = await createChallenge(updated, 'login', updated.twoFactorMethod || 'email');
return res.json({ twoFactorRequired: true, ...challenge });
}
// Admins and supervisors must use 2FA. Not set up yet: send a code to the
// first channel that can deliver one, and verifying it turns 2FA on. A
// trusted device can't skip this — nothing has been verified yet.
if (!updated.twoFactorEnabled && requiresTwoFactor(updated)) {
const method = await enforcementMethod(updated);
if (method) {
const challenge = await createChallenge(updated, 'login', method);
return res.json({ twoFactorRequired: true, enrolling: true, ...challenge });
}
}
await completeLogin(updated, req, res);
res.json(result.session);
} catch (error) {
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
}
@@ -373,30 +66,7 @@ const loginUser = async (req, res) => {
const verifyLoginTwoFactor = async (req, res) => {
try {
const { challengeId, code, rememberDevice } = req.body || {};
const challenge = await verifyChallenge(challengeId, code, 'login');
let user = await prisma.user.findUnique({ where: { id: challenge.userId }, select: LOGIN_USER_SELECT });
if (!user || !user.isActive) {
res.status(401);
throw new Error('Your account has been deactivated');
}
// First login since 2FA became required for this role: the code proves the
// channel works, so turn 2FA on with it.
if (!user.twoFactorEnabled && requiresTwoFactor(user)) {
user = await prisma.user.update({
where: { id: user.id },
data: { twoFactorEnabled: true, twoFactorMethod: challenge.method, updatedAt: new Date() },
select: LOGIN_USER_SELECT,
});
logSecurityEvent({ userId: user.id, type: 'two_factor_enabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
}
const extra = {};
if (rememberDevice === true) {
extra.deviceToken = await issueTrustedDevice(user.id, req.headers['user-agent']);
}
await completeLogin(user, req, res, extra);
res.json(await accounts.verifyLoginCode({ challengeId, code, rememberDevice }, { ip: getClientIp(req), userAgent: req.headers['user-agent'] }));
} catch (error) {
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
}
@@ -565,114 +235,10 @@ const getUserProfile = async (req, res) => {
// @access Private
const updateUserProfile = async (req, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user.id }
});
if (!user) {
res.status(404);
throw new Error('User not found');
}
const { name, email, password, phoneNumber, currentPassword, notificationPreference, homeCampusId } = req.body || {};
// Home campus (only while campuses are on): an active campus, or ''/null for none.
let newHomeCampusId = user.homeCampusId;
if (homeCampusId !== undefined && await require('../utils/campuses').campusesEnabled()) {
if (!homeCampusId) newHomeCampusId = null;
else {
const campus = await prisma.campus.findFirst({ where: { id: String(homeCampusId), archivedAt: null }, select: { id: true } });
if (!campus) { res.status(400); throw new Error('Choose one of the campuses in the list.'); }
newHomeCampusId = campus.id;
}
}
// If a password change is requested, verify current password for safety
let newHashedPassword = undefined;
if (typeof password === 'string' && password.trim().length > 0) {
const newPw = password.trim();
if (!currentPassword || typeof currentPassword !== 'string' || currentPassword.length === 0) {
res.status(400);
throw new Error('Current password is required to set a new password');
}
const matches = await comparePassword(currentPassword, user.password);
if (!matches) {
res.status(400);
throw new Error('Current password is incorrect');
}
if (newPw.length < 8) {
res.status(400);
throw new Error('New password must be at least 8 characters long');
}
newHashedPassword = await hashPassword(newPw);
}
// Normalize phone
const { normalizeZAPhone } = require('../utils/whatsapp');
let newPhone = user.phoneNumber;
if (phoneNumber !== undefined) {
newPhone = phoneNumber ? (normalizeZAPhone(phoneNumber) || phoneNumber.replace(/\D/g, '') || null) : null;
}
// Validate notification preference — WhatsApp requires a valid SA phone number
const { resolveNotificationPreference } = require('../utils/notificationPreference');
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
// 2FA codes can't go to WhatsApp without a usable number — move them to email
// rather than leave the account pointing at a channel that no longer exists.
const { isValidZAPhone } = require('../utils/whatsapp');
const newTwoFactorMethod = user.twoFactorMethod === 'whatsapp' && !isValidZAPhone(newPhone) ? 'email' : user.twoFactorMethod;
// Update user data
const updatedUser = await prisma.user.update({
where: { id: req.user.id },
data: {
name: name || user.name,
email: email || user.email,
password: newHashedPassword ? newHashedPassword : user.password,
phoneNumber: newPhone,
notificationPreference: newPref,
twoFactorMethod: newTwoFactorMethod,
homeCampusId: newHomeCampusId,
updatedAt: new Date()
},
select: {
id: true,
name: true,
email: true,
role: true,
phoneNumber: true,
notificationPreference: true,
twoFactorEnabled: true,
twoFactorMethod: true,
createdAt: true,
updatedAt: true,
isActive: true,
homeCampusId: true,
tokenVersion: true
}
});
// If the password was changed, send a security alert email (fire-and-forget)
if (newHashedPassword) {
await clearTrustedDevices(updatedUser.id);
const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail });
sendMail({ to: updatedUser.email, subject: `Your ${getOrgName()} password was changed`, ...content })
.catch(e => console.warn('[email] Failed to send password changed alert:', e?.message || e));
const { waText } = require('../utils/notify');
waText(updatedUser, content.text).catch(() => {});
logSecurityEvent({ userId: updatedUser.id, type: 'password_changed', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
}
res.json({
...updatedUser,
twoFactorRequired: requiresTwoFactor(updatedUser),
token: generateToken(updatedUser.id, updatedUser.role, updatedUser.tokenVersion)
});
const { user, twoFactorRequired, token } = await accounts.updateProfile(req.user.id, req.body || {}, { ip: getClientIp(req), userAgent: req.headers['user-agent'] });
res.json({ ...user, twoFactorRequired, token });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
}
};
@@ -973,55 +539,52 @@ const requestPasswordReset = async (req, res) => {
const user = await prisma.user.findUnique({ where: { email } });
// Explicitly check existence as requested
if (!user) {
return res.status(404).json({ message: 'Email not found' });
}
// Create token valid for 1 hour
const token = uuidv4();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
// If PasswordReset model isn't available (migration not run), return clear error
if (!prisma.passwordReset || typeof prisma.passwordReset.create !== 'function' || typeof prisma.passwordReset.updateMany !== 'function') {
console.warn('[PasswordReset] Prisma model not available. Run Prisma migrations to enable password reset tokens.');
return res.status(500).json({ message: 'Password reset is not available. Please contact support.' });
}
// Invalidate previous tokens (optional)
await prisma.passwordReset.updateMany({
where: { userId: user.id, used: false, expiresAt: { gt: new Date() } },
data: { used: true }
});
await prisma.passwordReset.create({
data: {
id: uuidv4(),
userId: user.id,
token,
expiresAt,
used: false
}
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`;
const { sendMail, buildPasswordResetEmail } = require('../utils/email');
const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl });
// Security: always email
sendMail({ to: user.email, subject: 'Reset your password', ...emailContent })
.catch(e => console.warn('[password reset email] Failed:', e?.message || e));
// Also WhatsApp if preferred (security message — sent in addition to email)
const { waText } = require('../utils/notify');
waText(user, emailContent.text).catch(() => {});
return res.json({ message: 'If that email exists, a password reset link has been sent.' });
// Security (1.51.17): the same answer, with the same status, whether or not an account has
// this email — and it's sent before any work that only happens for real accounts, so the
// response time doesn't tell either. The link is made and sent afterwards.
res.json({ message: GENERIC_RESET_MESSAGE });
if (user) sendPasswordResetLink(user).catch(e => console.warn('[password reset] Failed:', e?.message || e));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
const GENERIC_RESET_MESSAGE = 'If an account uses that email, we\'ve sent it a link to reset the password.';
/** A one-hour reset link for this account, by email (and WhatsApp when they prefer it). */
async function sendPasswordResetLink(user) {
const token = uuidv4();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
// Invalidate previous tokens (optional)
await prisma.passwordReset.updateMany({
where: { userId: user.id, used: false, expiresAt: { gt: new Date() } },
data: { used: true }
});
await prisma.passwordReset.create({
data: {
id: uuidv4(),
userId: user.id,
token,
expiresAt,
used: false
}
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`;
const { sendMail, buildPasswordResetEmail } = require('../utils/email');
const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl });
// Security: always email
sendMail({ to: user.email, subject: 'Reset your password', ...emailContent })
.catch(e => console.warn('[password reset email] Failed:', e?.message || e));
// Also WhatsApp if preferred (security message — sent in addition to email)
const { waText } = require('../utils/notify');
waText(user, emailContent.text).catch(() => {});
}
// @desc Reset password using token
// @route POST /api/users/reset
// @access Public
@@ -1114,6 +677,13 @@ const activateAccount = async (req, res) => {
select: { id: true, name: true, email: true, role: true, tokenVersion: true }
});
// The activation form asks them to agree to the terms and privacy notice, as sign-up does.
// Without it (an older page) they're asked once after login instead.
if (req.body.acceptTerms === true) {
await require('../utils/privacyNotice').acceptNotice(user.id, { source: 'activation', ip: getClientIp(req) })
.catch(e => console.warn('[activate] notice acceptance not saved:', e?.message || e));
}
// Send welcome email (skip guest/placeholder accounts)
if (updated.email && !updated.email.endsWith('@guest.local')) {
sendWelcomeEmail(updated).catch(() => {});
@@ -5,16 +5,6 @@ const { handleProviderWebhook } = require('../payments/webhook');
// @access Public
const handleYocoWebhook = handleProviderWebhook('yoco');
const handleWhatsappWebhook = async (req, res) => {
try {
const { body } = req;
const { message } = body;
} catch (error) {
res.status(500).json({ error: 'Internal Server Error' });
}
}
module.exports = {
handleYocoWebhook,
handleWhatsappWebhook,
};
+39 -15
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. */
@@ -145,17 +150,36 @@ async function heartbeat() {
}
}
/** a < b as x.y.z versions. */
function olderThan(a, b) {
const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0);
const pb = String(b || '').split('.').map(n => parseInt(n, 10) || 0);
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] < pb[i];
return false;
}
/**
* Ask the Hub for this site to be updated (§3a). Joshua is told; the site never updates itself.
* Returns { requestedAt, version, alreadyRequested }.
* Asking again after a newer version came out moves the request on to it (`changed`). A Hub before
* 1.5.0 can't do that (it doesn't send `changed`), so the old request is withdrawn and made again.
* Returns { requestedAt, version, alreadyRequested, changed, previousVersion }.
*/
async function requestUpdate() {
let data;
try {
data = await signedPost('/api/installs/update-request', { protocolVersion: PROTOCOL_VERSION, version: report.APP_VERSION });
} catch (e) {
if (e.code === 'NOT_FOUND') throw new HubError('The Hub doesn\'t take update requests yet. Please contact your provider.', 'NOT_SUPPORTED', 502);
throw e;
const ask = async () => {
try {
return await signedPost('/api/installs/update-request', { protocolVersion: PROTOCOL_VERSION, version: report.APP_VERSION });
} catch (e) {
if (e.code === 'NOT_FOUND') throw new HubError('The Hub doesn\'t take update requests yet. Please contact your provider.', 'NOT_SUPPORTED', 502);
throw e;
}
};
let data = await ask();
if (data.alreadyRequested && data.changed === undefined) {
const asked = (await read(['hub_update_requested_version'])).hub_update_requested_version || null;
if (!asked || olderThan(asked, data.version)) {
await cancelUpdateRequest();
data = { ...(await ask()), changed: true, previousVersion: asked };
}
}
await save({ hub_update_requested_at: data.requestedAt, hub_update_requested_version: data.version || null });
return data;
@@ -192,4 +216,4 @@ function heartbeatSoon(delayMs = 30 * 1000) {
soonTimer.unref?.();
}
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat, heartbeatSoon, requestUpdate, cancelUpdateRequest, snoozeUpdate };
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, signedPost, connect, heartbeat, heartbeatSoon, requestUpdate, cancelUpdateRequest, snoozeUpdate };
+25 -20
View File
@@ -10,27 +10,32 @@
const { licenceStatus } = require('./licence');
// /api/v2 only: 2.0 removed v1 (1.52.3 added these — the screens had used v2 since 1.51.15).
const BLOCKED = [
['POST', /^\/api\/events\/?$/],
['PUT', /^\/api\/events\/[^/]+\/?$/],
['POST', /^\/api\/events\/[^/]+\/copy-from\/?$/],
['POST', /^\/api\/events\/[^/]+\/options\/?$/],
['PUT', /^\/api\/events\/options\/[^/]+\/?$/],
['POST', /^\/api\/events\/options\/[^/]+\/variants\/?$/],
['PUT', /^\/api\/events\/variants\/[^/]+\/?$/],
['POST', /^\/api\/registrations\/?$/],
['POST', /^\/api\/registrations\/manual\/?$/],
['PUT', /^\/api\/registrations\/[^/]+\/options\/?$/],
['POST', /^\/api\/payments\/?$/],
['POST', /^\/api\/payments\/(checkout|yoco-checkout)(\/send)?\/?$/],
['POST', /^\/api\/payments\/pay\/[^/]+\/?$/],
['POST', /^\/api\/waitlist\/?$/],
['POST', /^\/api\/coupons\/?$/],
['POST', /^\/api\/bundles\/event\/[^/]+\/?$/],
// Volunteer rosters: new roles and new people on them (they can carry a free ticket).
['POST', /^\/api\/volunteers\/roles\/[^/]+\/sign-up\/?$/],
['POST', /^\/api\/volunteers\/manage\/events\/[^/]+\/roles\/?$/],
['POST', /^\/api\/volunteers\/manage\/roles\/[^/]+\/assign\/?$/],
['POST', /^\/api\/v2\/events\/?$/],
['PATCH', /^\/api\/v2\/events\/[^/]+\/?$/],
['POST', /^\/api\/v2\/events\/[^/]+\/copy-from\/?$/],
['POST', /^\/api\/v2\/series\/?$/],
['PATCH', /^\/api\/v2\/series\/[^/]+\/?$/],
['POST', /^\/api\/v2\/season-passes\/?$/],
['PATCH', /^\/api\/v2\/season-passes\/[^/]+\/?$/],
['POST', /^\/api\/v2\/season-passes\/[^/]+\/buy\/?$/],
['POST', /^\/api\/v2\/season-pass-purchases\/[^/]+\/checkout\/?$/],
['POST', /^\/api\/v2\/events\/[^/]+\/ticket-options\/?$/],
['PATCH', /^\/api\/v2\/ticket-options\/[^/]+\/?$/],
['POST', /^\/api\/v2\/ticket-options\/[^/]+\/variants\/?$/],
['PATCH', /^\/api\/v2\/variants\/[^/]+\/?$/],
['POST', /^\/api\/v2\/registrations\/?$/],
['POST', /^\/api\/v2\/registrations\/manual\/?$/],
['PUT', /^\/api\/v2\/registrations\/[^/]+\/items\/?$/],
['POST', /^\/api\/v2\/payments\/?$/],
['POST', /^\/api\/v2\/registrations\/[^/]+\/(checkout|payment-link(\/send)?)\/?$/],
['POST', /^\/api\/v2\/payment-links\/[^/]+\/?$/],
['POST', /^\/api\/v2\/waitlist\/?$/],
['POST', /^\/api\/v2\/coupons\/?$/],
['POST', /^\/api\/v2\/events\/[^/]+\/bundles\/?$/],
['POST', /^\/api\/v2\/volunteer-roles\/[^/]+\/(sign-up|assign)\/?$/],
['POST', /^\/api\/v2\/events\/[^/]+\/volunteer-roles\/?$/],
];
function isBlocked(method, path) {
+26 -1744
View File
File diff suppressed because it is too large Load Diff
+17 -9
View File
@@ -15,10 +15,13 @@
* same jobs run on in-process timers instead, as before.
*/
const cronParser = require('cron-parser');
const { CronExpressionParser } = require('cron-parser');
const TIMEZONE = process.env.APP_TIMEZONE || 'Africa/Johannesburg';
const SCHEMA = 'pgboss';
// pg-boss 11 can't upgrade pg-boss 10's schema (it migrates only from 11.0), so it has its own.
// The old `pgboss` schema (10.x) is left as it was; it can be dropped (DROP SCHEMA pgboss CASCADE)
// once 1.51.7 has run — it only held queued runs and their history.
const SCHEMA = 'pgboss_v11';
const JOBS = [
{
@@ -49,6 +52,14 @@ const JOBS = [
name: 'volunteers', label: 'Volunteer reminders the day before a shift', cron: '*/15 * * * *', env: 'VOLUNTEER_JOBS_ENABLED',
run: () => require('../utils/volunteers').runReminderJob(),
},
{
name: 'webhooks', label: 'Outbound webhooks: send what is due, retry failures, delete deliveries after 30 days', cron: '* * * * *', env: 'WEBHOOKS_JOBS_ENABLED',
run: () => require('../utils/webhooks').runJob(),
},
{
name: 'series', label: 'Event series: tell followers when a new event goes live', cron: '*/15 * * * *', env: 'SERIES_JOBS_ENABLED',
run: () => require('../utils/series').runAnnounceJob(),
},
{
name: 'consent', label: 'Parental consent reminders and medical-detail deletion', cron: '0 9 * * *', env: 'CONSENT_JOBS_ENABLED',
run: async () => {
@@ -97,7 +108,7 @@ function cronFor(job) {
const override = process.env[`JOB_SCHEDULE_${job.name.toUpperCase().replace(/-/g, '_')}`];
if (override) {
try {
cronParser.parseExpression(override, { tz: TIMEZONE });
CronExpressionParser.parse(override, { tz: TIMEZONE });
return override;
} catch {
console.warn(`[jobs] Ignoring invalid schedule for ${job.name}: "${override}"`);
@@ -108,7 +119,7 @@ function cronFor(job) {
/** When a cron schedule next fires after `from`. */
function nextRun(cron, from = new Date()) {
return cronParser.parseExpression(cron, { tz: TIMEZONE, currentDate: from }).next().toDate();
return CronExpressionParser.parse(cron, { tz: TIMEZONE, currentDate: from }).next().toDate();
}
function summarize(result) {
@@ -262,11 +273,8 @@ async function jobStatus(prisma) {
FROM (
SELECT name, state, created_on, started_on, completed_on, output,
row_number() OVER (PARTITION BY name ORDER BY created_on DESC) AS rn
FROM (
SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.job
UNION ALL
SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.archive
) all_jobs
-- pg-boss 11 keeps finished runs in job until they expire (there's no archive table).
FROM (SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.job) all_jobs
WHERE name = ANY($1::text[])
) ranked
WHERE rn <= 10
+6
View File
@@ -19,6 +19,12 @@ async function checkoutPaid(checkout) {
const refs = [checkout.id, checkout.externalId].filter(Boolean);
const tx = await prisma.providerTransaction.findFirst({ where: { checkoutId: { in: refs }, paymentId: { not: null } }, select: { id: true } });
if (tx) return true;
// A season pass checkout: paid once its purchase is.
const purchaseId = checkout.metadata?.purchaseId;
if (purchaseId) {
const purchase = await prisma.seasonPassPurchase.findUnique({ where: { id: String(purchaseId) }, select: { status: true } });
return purchase?.status === 'paid';
}
const registrationId = checkout.metadata?.registrationId;
if (!registrationId) return false;
const reg = await prisma.registration.findUnique({ where: { id: String(registrationId) }, select: { status: true } });
+8 -1
View File
@@ -71,7 +71,9 @@ const handleProviderWebhook = (providerId) => async (req, res) => {
if (!existingRegistration) {
const meta = tx.metadata || {};
const isDonation = meta.type === 'donation' || !!meta.isDonation || !!meta.eventId;
if (!isDonation) {
// A season pass checkout belongs to its purchase, not to one registration.
const isSeasonPass = meta.type === 'season_pass' && !!meta.purchaseId;
if (!isDonation && !isSeasonPass) {
try { await saveProviderTransaction(provider.id, event, body); } catch (e) { console.warn('Failed to save unreconciled transaction:', e?.message || e); }
return res.status(200).json({
success: true,
@@ -141,6 +143,11 @@ async function recordSucceededPayment(providerId, event, body) {
return { status: 'already_processed', paymentId: existingPayment.id, externalId };
}
// Season pass: split over the purchase's registrations, one Payment per event.
if (metadata.type === 'season_pass' && metadata.purchaseId) {
return require('../utils/seasonPass').recordPassPayment(providerId, event, body);
}
let registration = null;
const refs = event.checkoutRefs || [checkoutId].filter(Boolean);
if (refs.length) registration = await prisma.registration.findFirst({ where: { checkoutId: { in: refs } }, include: REG_INCLUDE });
-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;
+23
View File
@@ -0,0 +1,23 @@
const express = require('express');
const router = express.Router();
const { protect, staff } = require('../middleware/authMiddleware');
const { scopeList } = require('../utils/campusScope');
const dashboard = require('../utils/dashboard');
// Both follow the person's campus scope inside utils/dashboard.js.
// @desc Today's events and how they're going, money in, and what's waiting (1.49.0)
// @route GET /api/dashboard/today
// @access Private/Staff (money and waiting items for supervisors and admins)
router.get('/today', protect, staff, scopeList, async (req, res) => {
try { res.json(await dashboard.today(req)); } catch (e) { res.status(400).json({ message: e.message }); }
});
// @desc Command palette search (?q=): events; supervisors also payments by amount; admins people and registrations
// @route GET /api/dashboard/search
// @access Private/Staff
router.get('/search', protect, staff, scopeList, async (req, res) => {
try { res.json({ results: await dashboard.search(req, req.query.q) }); } catch (e) { res.status(400).json({ message: e.message }); }
});
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;
-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;
-85
View File
@@ -1,85 +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,
listOperators,
createOperator,
updateOperator,
deleteOperator,
consentLog,
accessLog,
getReadiness,
getRetention,
saveRetention,
runRetentionNow,
listBreaches,
createBreach,
updateBreach,
} = require('../controllers/privacyAdminController');
const { protect, admin } = require('../middleware/authMiddleware');
// Exports are heavy-ish (every record the person has) — a few per 10 minutes is plenty.
const exportLimiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false });
// The person themselves
router.get('/me', protect, getMyPrivacy);
router.put('/me/news', protect, updateMyNews);
router.get('/me/export', protect, exportLimiter, exportMyData);
router.post('/me/requests', protect, createMyRequest);
// Unsubscribe links (signed tokens, no login)
router.get('/unsubscribe/:token', getUnsubscribe);
router.post('/unsubscribe/:token', postUnsubscribe);
router.get('/unsubscribe/:token/one-click', oneClickRedirect);
router.post('/unsubscribe/:token/one-click', express.urlencoded({ extended: false }), oneClickUnsubscribe);
// Admin: data requests inbox
router.get('/requests', protect, admin, listRequests);
router.post('/requests/:id/complete', protect, admin, completeRequest);
router.post('/requests/:id/reject', protect, admin, rejectRequest);
// Privacy notice: public reading, acceptance, and the admin editor
router.get('/notice', getNotice);
router.post('/me/notice/accept', protect, acceptMyNotice);
router.get('/notice/draft', protect, admin, getNoticeDraft);
router.put('/notice/draft', protect, admin, saveNoticeDraft);
router.delete('/notice/draft', protect, admin, discardNoticeDraft);
router.post('/notice/publish', protect, admin, publishNotice);
// Admin: operator register, consent log, access log
router.get('/operators', protect, admin, listOperators);
router.post('/operators', protect, admin, createOperator);
router.put('/operators/:id', protect, admin, updateOperator);
router.delete('/operators/:id', protect, admin, deleteOperator);
router.get('/consent-log', protect, admin, consentLog);
router.get('/access-log', protect, admin, accessLog);
// Admin: readiness, retention, breach register
router.get('/readiness', protect, admin, getReadiness);
router.get('/retention', protect, admin, getRetention);
router.put('/retention', protect, admin, saveRetention);
router.post('/retention/run', protect, admin, runRetentionNow);
router.get('/breaches', protect, admin, listBreaches);
router.post('/breaches', protect, admin, createBreach);
router.put('/breaches/:id', protect, admin, updateBreach);
module.exports = router;
@@ -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;
-49
View File
@@ -1,49 +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 } = require('../utils/campusScope');
// Campus-scoped staff (utils/campusScope.js). Routes open to the registration's owner as well
// as to staff check staff against the registration's event, and let the owner through (ownerOk).
// Registration - auth optional when event doesn't require it
router.post('/', optionalAuth, createRegistration);
router.get('/myregistrations', protect, getUserRegistrations);
router.put('/:id/options', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), updateRegistrationOptions);
router.delete('/:id', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), cancelRegistration);
router.post('/:id/send-invoice', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), sendInvoice);
router.post('/:id/coupon', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), applyCoupon);
router.delete('/:id/coupon', protect, staff, scopeEvent({ resolve: 'registration' }), removeCoupon);
router.put('/:id/attendees', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), updateAttendees);
router.post('/:id/attendees/:attendeeId/send-form-link', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), sendAttendeeFormLink);
// Registration detail + forms — optionalAuth so guests can access with just the registrationId
router.get('/:id', optionalAuth, 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);
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;
+24
View File
@@ -0,0 +1,24 @@
const express = require('express');
const router = express.Router();
const { protect, staff } = require('../middleware/authMiddleware');
const { campusIndependent } = require('../utils/campusScope');
const filters = require('../utils/savedFilters');
const OWN = campusIndependent('a person\'s own saved filters (the lists they filter are campus-checked themselves)');
const fail = (res, e) => res.status(e.statusCode || 400).json({ message: e.message });
// Saved filters on list pages (1.49.2) — always the signed-in person's own.
router.get('/', protect, staff, OWN, async (req, res) => {
try { res.json(await filters.list(req.user.id, req.query.page)); } catch (e) { fail(res, e); }
});
router.post('/', protect, staff, OWN, async (req, res) => {
try { res.status(201).json(await filters.save(req.user.id, req.body || {})); } catch (e) { fail(res, e); }
});
router.put('/:id', protect, staff, OWN, async (req, res) => {
try { res.json(await filters.update(req.user.id, req.params.id, req.body || {})); } catch (e) { fail(res, e); }
});
router.delete('/:id', protect, staff, OWN, async (req, res) => {
try { await filters.remove(req.user.id, req.params.id); res.json({ ok: true }); } catch (e) { fail(res, e); }
});
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;
-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;
-12
View File
@@ -1,12 +0,0 @@
const express = require('express');
const router = express.Router();
const { getSettings, getAllSettings, updateSettings, needsSetup, testSmtp } = require('../controllers/settingsController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/needs-setup', needsSetup);
router.get('/all', protect, admin, getAllSettings);
router.get('/', getSettings);
router.put('/', protect, admin, updateSettings);
router.post('/test-smtp', protect, admin, testSmtp);
module.exports = router;
-12
View File
@@ -1,12 +0,0 @@
const express = require('express');
const router = express.Router();
const { setupRegister, runSetup } = require('../controllers/settingsController');
const { protect, admin } = require('../middleware/authMiddleware');
// Step 1 of setup: create the admin account, returns a JWT token
router.post('/register', setupRegister);
// Step 2 of setup: finalize settings (requires the token from /register)
router.post('/', protect, admin, runSetup);
module.exports = router;
-11
View File
@@ -1,11 +0,0 @@
const express = require('express');
const router = express.Router();
const { getEventSponsorship, createSponsorCheckout, getPool } = require('../controllers/sponsorshipController');
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
const { scopeEvent } = require('../utils/campusScope');
router.get('/event/:eventId', getEventSponsorship);
router.get('/event/:eventId/pool', protect, supervisor, scopeEvent(), getPool);
router.post('/checkout', optionalAuth, createSponsorCheckout);
module.exports = router;
-18
View File
@@ -1,18 +0,0 @@
const express = require('express');
const router = express.Router();
const { getStaffDashboardStats, getSupervisorDashboardStats, getAdminDashboardStats, getOverviewStats } = require('../controllers/statsController');
const { protect, staff, supervisor, admin } = require('../middleware/authMiddleware');
const { scopeList } = require('../utils/campusScope');
// One endpoint per dashboard — each returns exactly what that dashboard renders in a
// single response instead of the dashboard making several separate calls (and, previously,
// pulling full payments/events lists just to reduce them to a couple of numbers).
router.get('/staff', protect, staff, scopeList, getStaffDashboardStats);
router.get('/supervisor', protect, supervisor, scopeList, getSupervisorDashboardStats);
router.get('/admin', protect, admin, getAdminDashboardStats);
// Month-over-month KPIs + trend + top events, shared by the Admin and Supervisor dashboards.
// Staff never sees financial data, so this has no staff-accessible route.
router.get('/overview', protect, supervisor, scopeList, getOverviewStats);
module.exports = router;
-55
View File
@@ -1,55 +0,0 @@
const express = require('express');
const router = express.Router();
const {
generateTickets,
getTickets,
getUserTickets,
getTicketById,
getTicketByQrCode,
getScanPreview,
scanTicket,
getTicketsByEvent,
markTicketsAsEmailSent,
emailTickets,
sendTicketsTo,
getRecentScans,
getScanStats,
transferTicketHandler,
cancelTransferHandler,
getTransferOffer,
acceptTransferOffer,
declineTransferOffer,
getTicketTransfers
} = require('../controllers/ticketController');
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
const { scopeEvent, scopeBody, scopeList } = require('../utils/campusScope');
// Campus-scoped staff (utils/campusScope.js). The scanner names the campus of another campus's ticket.
// Protected routes
router.get('/mytickets', protect, getUserTickets);
router.get('/:id', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), getTicketById);
router.post('/email', protect, emailTickets);
router.post('/:id/transfer', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), transferTicketHandler);
router.delete('/:id/transfer', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), cancelTransferHandler);
// Transfer links (public: the person the ticket is offered to)
router.get('/transfer-offer/:token', getTransferOffer);
router.post('/transfer-offer/:token/accept', acceptTransferOffer);
router.post('/transfer-offer/:token/decline', declineTransferOffer);
router.get('/:id/transfers', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), getTicketTransfers);
// Staff routes
router.post('/send-to', protect, staff, scopeBody([['registrationId', 'registration'], ['ticketIds', 'ticket']]), sendTicketsTo);
router.get('/scan-preview/:qrCode', protect, staff, scopeEvent({ resolve: 'ticketQr', from: req => req.params.qrCode, nameCampus: true }), getScanPreview);
router.get('/qr/:qrCode', protect, staff, scopeEvent({ resolve: 'ticketQr', from: req => req.params.qrCode, nameCampus: true }), getTicketByQrCode);
router.post('/scan/:qrCode', protect, staff, scopeEvent({ resolve: 'ticketQr', from: req => req.params.qrCode, nameCampus: true }), scanTicket);
router.get('/event/:eventId', protect, staff, scopeEvent(), getTicketsByEvent);
router.get('/scans/recent', protect, staff, scopeList, getRecentScans);
router.get('/scans/stats', protect, staff, scopeList, getScanStats);
// Admin routes
router.post('/generate', protect, supervisor, scopeBody([['registrationId', 'registration']]), generateTickets);
router.get('/', protect, supervisor, scopeList, getTickets);
router.put('/email-sent', protect, admin, markTicketsAsEmailSent);
module.exports = router;
-53
View File
@@ -1,53 +0,0 @@
const express = require('express');
const router = express.Router();
const { upload, uploadEventImage, uploadLogo, uploadLogoImage, uploadFavicon, uploadFaviconImage } = require('../controllers/uploadController');
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
const { campusIndependent } = require('../utils/campusScope');
const prisma = require('../config/db');
// @route POST /api/uploads/event-image
// @desc Upload an image for an event
// @access Private (Supervisor+ recommended)
router.post('/event-image', protect, supervisor, campusIndependent('stores an image file; the event it goes on is checked when the event is saved'), (req, res, next) => {
upload.single('image')(req, res, (err) => {
if (err) {
req.multerError = err;
}
next();
});
}, uploadEventImage);
// Shared access rule for branding assets (logo, favicon): admin, OR allowed
// during first-time setup (no users yet).
async function brandingAssetAccess(req, res, next) {
try {
const count = await prisma.user.count();
if (count === 0) return next(); // first-time setup
return protect(req, res, () => admin(req, res, next));
} catch {
return protect(req, res, () => admin(req, res, next));
}
}
// @route POST /api/uploads/logo
// @desc Upload site logo — admin, OR allowed during first-time setup (no users yet)
// @access Admin or setup
router.post('/logo', brandingAssetAccess, (req, res, next) => {
uploadLogo.single('image')(req, res, (err) => {
if (err) req.multerError = err;
next();
});
}, uploadLogoImage);
// @route POST /api/uploads/favicon
// @desc Upload site favicon — admin, OR allowed during first-time setup (no users yet)
// @access Admin or setup
router.post('/favicon', brandingAssetAccess, (req, res, next) => {
uploadFavicon.single('image')(req, res, (err) => {
if (err) req.multerError = err;
next();
});
}, uploadFaviconImage);
module.exports = router;
-67
View File
@@ -1,67 +0,0 @@
const express = require('express');
const router = express.Router();
const {
registerUser,
loginUser,
verifyLoginTwoFactor,
resendLoginTwoFactor,
startEnableTwoFactor,
confirmEnableTwoFactor,
disableTwoFactor,
verifyPassword,
getUserProfile,
updateUserProfile,
getUsers,
checkUserExists,
getUserById,
updateUser,
deleteUser,
anonymizeUser,
requestPasswordReset,
resetPassword,
activateAccount,
revokeMySession,
adminRevokeUserSessions,
closeAccount,
getMyActivity,
} = require('../controllers/userController');
const { protect, admin, supervisor, loginLimiter} = require('../middleware/authMiddleware');
const { campusIndependent } = require('../utils/campusScope');
// Public routes
router.post('/', registerUser);
router.post('/login', loginLimiter, loginUser);
router.post('/login/2fa', loginLimiter, verifyLoginTwoFactor);
router.post('/login/2fa/resend', loginLimiter, resendLoginTwoFactor);
router.post('/forgot', requestPasswordReset);
router.post('/reset', resetPassword);
router.post('/activate', activateAccount);
// Protected routes
router.route('/profile')
.get(protect, getUserProfile)
.put(protect, updateUserProfile);
router.post('/revoke-sessions', protect, revokeMySession);
router.post('/close-account', protect, closeAccount);
router.get('/activity', protect, getMyActivity);
router.post('/verify-password', protect, loginLimiter, verifyPassword);
router.post('/2fa/enable', protect, loginLimiter, startEnableTwoFactor);
router.post('/2fa/confirm', protect, loginLimiter, confirmEnableTwoFactor);
router.post('/2fa/disable', protect, loginLimiter, disableTwoFactor);
// Admin routes
router.route('/')
.get(protect, supervisor, campusIndependent('accounts are shared across campuses'), getUsers);
router.get('/check-exists', protect, supervisor, campusIndependent('accounts are shared across campuses'), checkUserExists);
router.route('/:id')
.get(protect, admin, getUserById)
.put(protect, admin, updateUser)
.delete(protect, admin, deleteUser);
router.post('/:id/revoke-sessions', protect, admin, adminRevokeUserSessions);
router.post('/:id/anonymize', protect, admin, anonymizeUser);
module.exports = router;
-33
View File
@@ -1,33 +0,0 @@
const express = require('express');
const router = express.Router();
const c = require('../controllers/volunteerController');
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
const { scopeEvent, scopeList } = require('../utils/campusScope');
// My account → Serving (anyone with an account can volunteer; only their own slots)
router.get('/me', protect, c.getMine);
router.get('/events/:eventId', protect, c.getEventRoles);
router.post('/roles/:id/sign-up', protect, c.signUp);
router.delete('/slots/:id', protect, c.withdraw);
router.post('/slots/:id/swap', protect, c.requestSwap);
router.delete('/swaps/:id', protect, c.cancelSwap);
router.post('/swaps/:id/accept', protect, c.acceptSwap);
router.post('/check-in', protect, c.checkIn);
// The roster on the day (staff)
router.get('/manage/events', protect, staff, scopeList, c.listEvents);
router.get('/manage/events/:eventId', protect, staff, scopeEvent(), c.getRoster);
router.get('/manage/events/:eventId/qr', protect, staff, scopeEvent(), c.getQr);
router.post('/manage/slots/:id/check-in', protect, staff, scopeEvent({ resolve: 'volunteerSlot' }), c.checkInManual);
// Roles, people, settings and reports (supervisors)
router.get('/manage/events/:eventId/settings', protect, supervisor, scopeEvent(), c.getSettings);
router.put('/manage/events/:eventId/settings', protect, supervisor, scopeEvent({ need: 'manage' }), c.saveSettings);
router.post('/manage/events/:eventId/roles', protect, supervisor, scopeEvent({ need: 'manage' }), c.createRole);
router.put('/manage/roles/:id', protect, supervisor, scopeEvent({ resolve: 'servingRole', need: 'manage' }), c.updateRole);
router.delete('/manage/roles/:id', protect, supervisor, scopeEvent({ resolve: 'servingRole', need: 'manage' }), c.deleteRole);
router.post('/manage/roles/:id/assign', protect, supervisor, scopeEvent({ resolve: 'servingRole' }), c.assign);
router.delete('/manage/slots/:id', protect, supervisor, scopeEvent({ resolve: 'volunteerSlot' }), c.remove);
router.get('/manage/hours', protect, supervisor, scopeList, c.hours);
module.exports = router;
-20
View File
@@ -1,20 +0,0 @@
const express = require('express');
const rateLimit = require('express-rate-limit');
const router = express.Router();
const { joinWaitlist, myWaitlist, leaveWaitlist, claimInfo, eventWaitlist, offerManually, moveEntry, removeEntry } = require('../controllers/waitlistController');
const { protect, staff, supervisor, optionalAuth } = require('../middleware/authMiddleware');
const { scopeEvent } = require('../utils/campusScope');
// Claim links are credentials, so keep guessing slow.
const claimLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
router.post('/', optionalAuth, joinWaitlist);
router.get('/mine', protect, myWaitlist);
router.get('/claim/:token', claimLimiter, claimInfo);
router.get('/events/:eventId', protect, staff, scopeEvent(), eventWaitlist);
router.post('/:id/offer', protect, supervisor, scopeEvent({ resolve: 'waitlistEntry' }), offerManually);
router.post('/:id/move', protect, supervisor, scopeEvent({ resolve: 'waitlistEntry' }), moveEntry);
router.post('/:id/remove', protect, supervisor, scopeEvent({ resolve: 'waitlistEntry' }), removeEntry);
router.delete('/:id', protect, leaveWaitlist);
module.exports = router;
+3 -2
View File
@@ -1,11 +1,12 @@
const express = require('express');
const router = express.Router();
const { handleYocoWebhook, handleWhatsappWebhook } = require('../controllers/webhookController');
const { handleYocoWebhook } = require('../controllers/webhookController');
const { handleProviderWebhook } = require('../payments/webhook');
// Payment provider webhooks (src/payments)
router.post('/yoco', handleYocoWebhook);
router.post('/payfast', handleProviderWebhook('payfast'));
router.post('/whatsapp', handleWhatsappWebhook);
// The WAWP handler (WAWP is registered at /api/whatsapp/webhook, now /api/v2/webhooks/whatsapp).
router.post('/whatsapp', ...require('../v2/links').whatsappWebhook);
module.exports = router;
@@ -1,11 +0,0 @@
const express = require('express');
const router = express.Router();
const { previewWhatsAppBroadcast, sendWhatsAppBroadcast, scheduleWhatsAppBroadcast } = require('../controllers/whatsappBroadcastController');
const { protect, supervisor } = require('../middleware/authMiddleware');
const { scopeBroadcast } = require('../utils/campusScope');
router.post('/preview', protect, supervisor, scopeBroadcast({ freeformField: 'phones' }), previewWhatsAppBroadcast);
router.post('/send', protect, supervisor, scopeBroadcast({ freeformField: 'phones' }), sendWhatsAppBroadcast);
router.post('/schedule', protect, supervisor, scopeBroadcast({ freeformField: 'phones' }), scheduleWhatsAppBroadcast);
module.exports = router;
-37
View File
@@ -1,37 +0,0 @@
const express = require('express');
const router = express.Router();
const {
getConfigHandler,
saveConfigHandler,
createInstanceHandler,
deleteInstanceHandler,
getStatusHandler,
getQrHandler,
requestCodeHandler,
logoutHandler,
startHandler,
restartHandler,
handleWebhook,
} = require('../controllers/whatsappController');
const { protect, admin } = require('../middleware/authMiddleware');
// Public webhook (called by WAWP servers)
router.post('/webhook', handleWebhook);
// Admin-only: credentials config
router.get( '/config', protect, admin, getConfigHandler);
router.post('/config', protect, admin, saveConfigHandler);
// Admin-only: instance lifecycle
router.post('/create-instance', protect, admin, createInstanceHandler);
router.post('/delete-instance', protect, admin, deleteInstanceHandler);
// Admin-only: session management
router.get( '/status', protect, admin, getStatusHandler);
router.get( '/qr', protect, admin, getQrHandler);
router.post('/request-code', protect, admin, requestCodeHandler);
router.post('/logout', protect, admin, logoutHandler);
router.post('/start', protect, admin, startHandler);
router.post('/restart', protect, admin, restartHandler);
module.exports = router;
-12
View File
@@ -1,12 +0,0 @@
const express = require('express');
const router = express.Router();
const { getConfig, updateConfig, getWebhooks, createWebhook, removeWebhook } = require('../controllers/yocoController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/config', protect, admin, getConfig);
router.put('/config', protect, admin, updateConfig);
router.get('/webhooks', protect, admin, getWebhooks);
router.post('/webhooks', protect, admin, createWebhook);
router.delete('/webhooks/:id', protect, admin, removeWebhook);
module.exports = router;

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