Compare commits

...
20 Commits
Author SHA1 Message Date
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
104 changed files with 13520 additions and 5681 deletions
+181
View File
@@ -7,6 +7,187 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [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
+21 -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,30 @@ 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.
### 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 |
+21 -2
View File
@@ -415,6 +415,12 @@ logout controls. Disconnected sessions are recovered automatically.
## Supervisor tools
- **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,
@@ -516,7 +522,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.
@@ -822,6 +828,10 @@ Site settings → **Webhooks** (admins): send what happens to other systems as i
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
@@ -879,7 +889,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 (`/`) and built-in API docs page (`/docs`); **API v2** (`/api/v2`, 1.51.0)
with one error shape, paged lists and an OpenAPI 3.1 reference at `/docs/v2` (admins). Public
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
+1 -1
View File
@@ -107,7 +107,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**
+49
View File
@@ -12,6 +12,55 @@ 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".
-->
## 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
+9 -1
View File
@@ -2,6 +2,14 @@
Full interactive docs (with request/response examples) are available at `GET /docs?token=<admin-jwt>` on the running backend.
> **API v2 (1.51.0 on):** `/api/v2` is described by OpenAPI 3.1. The reference is at `/docs/v2`
> (admin token), the document at `GET /api/v2/openapi.json` (admin). It is generated from the route
> declarations, so it is not repeated here. v2 responses are `{ data }` (lists add
> `meta: { page, limit, total, pages }`, `?page=&limit=` up to 100) and errors
> `{ error: { code, message, fields? } }`. This file documents v1 (`/api/…`). v1 endpoints that have a v2 replacement send `Deprecation` and
> `Link: <v2 path>; rel="successor-version"` headers (1.51.3). The frontend calls v2 through the typed
> `apiV2()` helper (`frontend/src/lib/apiV2.ts`, types regenerated with `npm run api:types`).
All authenticated requests require:
```
Authorization: Bearer <jwt>
@@ -999,7 +1007,7 @@ where v1 = HMAC-SHA256(secret, `<t>.<raw body>`). Answer 2xx within 10 s; anythi
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/outbound-webhooks` | Endpoints with last-week counts, and the event types |
| GET | `/api/outbound-webhooks` | Endpoints with last-week counts, and the event types (each with an `example` of its `data`) |
| POST | `/api/outbound-webhooks` | `{ name, url, events }` → endpoint plus `secret` (only here) |
| PUT | `/api/outbound-webhooks/:id` | `{ name?, url?, events?, isActive? }` |
| DELETE | `/api/outbound-webhooks/:id` | Endpoint and its deliveries |
+12 -3
View File
@@ -152,14 +152,22 @@ npm run prisma:generate
## 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/'],
};
+1403 -1339
View File
File diff suppressed because it is too large Load Diff
+31 -11
View File
@@ -1,11 +1,11 @@
{
"name": "event-management-backend",
"version": "1.50.1",
"version": "1.51.6",
"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,49 @@
"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"
},
"dependencies": {
"@cj-tech-master/excelts": "^10.2.0",
"@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",
"multer": "^2.5.0",
"node-fetch": "^2.7.0",
"nodemailer": "^10.0.16",
"nodemailer": "^10.3.0",
"pdfkit": "^0.17.1",
"pg-boss": "^10.4.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 +69,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"
}
}
+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);
}
+1826
View File
File diff suppressed because it is too large Load Diff
@@ -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');
+7 -48
View File
@@ -6,7 +6,6 @@ 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');
@@ -941,54 +940,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);
}
+15 -581
View File
@@ -3,534 +3,33 @@ const { v4: uuidv4 } = require('uuid');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { emailTickets } = require('./ticketController');
const { hashPassword } = require('../config/auth');
const { resolveOptionPrice, resolveVariantTierPrice, computeRegistrationTotalDue, refreshPricingForRegistration, attachComputedTotals, attachComputedTotalsToList } = require('../utils/pricing');
const { computeRegistrationTotalDue, refreshPricingForRegistration, attachComputedTotals, attachComputedTotalsToList } = require('../utils/pricing');
const { assertEventOpen } = require('../utils/cashupUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { buildCouponLines, previewCoupon, applyCouponToRegistration, recalculateRegistrationDiscount, releaseRegistrationCoupons, describeCoupon } = require('../utils/coupons');
const { lineKey, validateAttendees, createAttendees, namedPlacesByKey } = require('../utils/attendees');
const { previewCoupon, applyCouponToRegistration, recalculateRegistrationDiscount, releaseRegistrationCoupons, describeCoupon } = require('../utils/coupons');
const { validateAttendees, createAttendees, namedPlacesByKey } = require('../utils/attendees');
const { findAccountByContact } = require('../utils/accountMatch');
const { ensureConsentRequests } = require('../utils/consent');
const { takenCount } = require('../utils/stock');
const { findOfferByToken, queueWaitlist } = require('../utils/waitlist');
const { queueWaitlist } = require('../utils/waitlist');
const { reservedOptionIds } = require('../utils/volunteers');
// Youth events: send parental consent requests for any new under-18 attendees. Runs in
// the background so a slow mail/WhatsApp send never holds up the registration.
function queueConsentRequests(registrationId) {
ensureConsentRequests(registrationId).catch(e => console.error('[consent] Failed to send requests:', e?.message || e));
}
// Named attendees for API responses, with whether their ticket has been issued/used.
const ATTENDEES_INCLUDE = {
orderBy: { order: 'asc' },
include: {
ticket: { select: { id: true, isUsed: true } },
// Latest consent request (youth events) — it decides check-in.
consentRequests: { orderBy: { createdAt: 'desc' }, take: 1, select: { id: true, status: true, respondedAt: true, lastSentAt: true, textVersion: true } },
},
};
const TICKET_DELIVERY = ['payer', 'each'];
/**
* Places each line will have once `requested` is added to `existingLines` — used to
* check attendee names against what's actually being bought.
*/
function placesAfter(existingLines, requested) {
const out = {};
// Picked-session lines take no attendee names.
for (const l of [...(existingLines || []), ...(requested || [])].filter(x => !x.sessionId)) {
const k = lineKey(l.eventOptionId, l.variantId);
out[k] = (out[k] || 0) + Number(l.quantity || 0);
}
return out;
}
// Active coupon on a registration, for API responses (the discount itself is
// Registration.discountTotal).
const ACTIVE_COUPONS_INCLUDE = {
where: { releasedAt: null },
select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } },
};
// Shared with POST /api/v2/registrations: the registration service (TypeScript, 1.51.1).
const registrationService = require('../services/registrations');
const {
queueConsentRequests, ATTENDEES_INCLUDE, TICKET_DELIVERY, placesAfter, ACTIVE_COUPONS_INCLUDE,
pendingCouponLines, priceRequestedOption, checkOptionStock, checkVariantStock, isStaffRole,
} = registrationService;
const { BUNDLE_APPS_INCLUDE } = require('../utils/bundles');
const { registrationClosesAt, wholeCourseClosesAt, expandPickLines, assertSessionCapacity, isPickOption, mergeSessionIds } = require('../utils/sessions');
/**
* Coupon lines for options that haven't been saved yet, so a code can be checked
* before anything is created. Mirrors buildCouponLines for one tranche per option.
*/
function pendingCouponLines(event, resolvedOptions) {
return buildCouponLines({
registrationOptions: resolvedOptions.map(o => ({
eventOptionId: o.eventOptionId,
variantId: o.variantId || null,
quantity: o.quantity,
eventOption: event.eventOptions.find(eo => eo.id === o.eventOptionId),
tranches: [{ quantity: o.quantity, priceSnapshot: o.priceSnapshot, appliedTierId: o.appliedTierId }],
})),
});
}
/**
* Price one requested option for this buyer: early-bird tiers, then variant/option price,
* then the staff price for Staff and above. Returns { priceSnapshot, appliedTierId }.
*/
async function priceRequestedOption(eventOption, variantId, qty, buyer) {
try {
const resolved = variantId
? await resolveVariantTierPrice(eventOption, variantId, qty, buyer)
: await resolveOptionPrice(eventOption, qty, buyer);
return { priceSnapshot: resolved.price, appliedTierId: resolved.tierId };
} catch (e) {
const variant = variantId ? (eventOption.variants || []).find(v => v.id === variantId) : null;
const price = variant && variant.price !== null && variant.price !== undefined ? variant.price : eventOption.price;
return { priceSnapshot: Number(price || 0), appliedTierId: null };
}
}
/**
* Check overall stock availability for an EventOption. Places held for live waitlist
* offers count as taken, except the offer being claimed (`excludeEntryId`).
* Returns { available: boolean, remaining: number|null }
*/
async function checkOptionStock(option, requestedQty, excludeEntryId = null, sessionId = null) {
if (!option.stockLimit || option.stockLimit === 0) return { available: true, remaining: null };
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id, sessionId }, { excludeEntryId });
return { available: remaining >= requestedQty, remaining };
}
/**
* Check stock availability for a specific variant (same rules as checkOptionStock).
*/
async function checkVariantStock(variant, requestedQty, excludeEntryId = null, sessionId = null) {
if (!variant.stockLimit || variant.stockLimit === 0) return { available: true, remaining: null };
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id, sessionId }, { excludeEntryId });
return { available: remaining >= requestedQty, remaining };
}
const { expandPickLines, assertSessionCapacity, isPickOption, mergeSessionIds } = require('../utils/sessions');
// @desc Create a new registration
// @route POST /api/registrations
// @access Private
const createRegistration = async (req, res) => {
try {
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken, analyticsSessionId } = req.body;
// Check if event exists
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
const optionInclude = canIncludeTiers
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: true } : {}) }
: true;
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: true }
});
if (!event) {
res.status(404);
throw new Error('Event not found');
}
if (!event.isActive) {
res.status(400);
throw new Error('Event is not active');
}
if (event.cashupStatus === 'closed') {
res.status(400);
throw new Error('This event is closed and no longer accepting registrations.');
}
// A waitlist claim link: its token identifies the person and releases the places
// held for them. It works without logging in, the same way a consent link does.
let waitlistOffer = null;
if (waitlistToken) {
waitlistOffer = await findOfferByToken(waitlistToken);
if (!waitlistOffer || waitlistOffer.eventId !== eventId) { res.status(400); throw new Error('This waitlist link is not valid.'); }
if (waitlistOffer.status !== 'offered') { res.status(400); throw new Error(`This waitlist offer has ${waitlistOffer.status === 'claimed' ? 'already been used' : 'expired'}.`); }
if (new Date(waitlistOffer.offerExpiresAt) < new Date()) { res.status(400); throw new Error('This waitlist offer has expired.'); }
if (req.user && req.user.id !== waitlistOffer.userId) { res.status(403); throw new Error('This waitlist offer belongs to someone else.'); }
}
// Enforce auth unless event explicitly opts out
const eventRequiresAuth = event.requiresAuth !== false;
if (eventRequiresAuth && !req.user && !waitlistOffer) {
res.status(401);
throw new Error('Authentication required for this event');
}
// Unauthenticated guest registration is only permitted for free events
if (!req.user && !waitlistOffer) {
const totalDueCheck = (event.eventOptions || []).reduce((sum, eo) => sum + (eo.price || 0), 0);
// Also check against the requested options specifically
const requestedTotal = (options || []).reduce((sum, opt) => {
const eo = event.eventOptions.find(o => o.id === opt.eventOptionId);
return sum + ((eo?.price || 0) * (opt.quantity || 1));
}, 0);
if (requestedTotal > 0) {
res.status(400);
throw new Error('Guest registration is only available for free events. Please create an account to pay for tickets.');
}
}
// Resolve userId — authenticated or guest
let userId;
if (req.user) {
userId = req.user.id;
} else if (waitlistOffer) {
userId = waitlistOffer.userId;
} else {
// Guest registration: find existing user by email or create inactive placeholder
if (!guestEmail || !guestName) {
res.status(400);
throw new Error('Name and email are required for unauthenticated registration');
}
const match = await findAccountByContact({ email: guestEmail, phone: guestPhone });
if (match.conflict) {
res.status(409);
throw new Error('This email and phone number belong to two different accounts. Please log in, or check your details.');
}
const existingGuest = match.user;
if (existingGuest) {
userId = existingGuest.id;
} else {
const newGuest = await prisma.user.create({
data: {
id: uuidv4(),
name: guestName,
email: guestEmail,
password: '',
phoneNumber: guestPhone || null,
isActive: false,
updatedAt: new Date(),
}
});
userId = newGuest.id;
}
}
// Enforce registration cutoff for public registrations (multi-day events close when
// the first session starts, or later with late joining — see utils/sessions.js).
const now = new Date();
if (now >= registrationClosesAt(event, event.sessions)) {
res.status(400);
throw new Error('Registration is closed for this event');
}
// Validate options
if (!options || !Array.isArray(options) || options.length === 0) {
res.status(400);
throw new Error('At least one option must be selected');
}
// "Pick sessions" options become one line per chosen session; whole-course tickets
// on such an event still close when the whole course does.
const lines = expandPickLines(event, options, { now });
// (An all-sessions ticket with sessions ticked is checked per session instead.)
if (event.attendanceMode === 'pick' && lines.some(l => !l.sessionId && !l.sessionIds?.length) && now >= wholeCourseClosesAt(event, event.sessions)) {
res.status(400);
throw new Error('Whole-course tickets are no longer on sale — choose the sessions you want instead.');
}
await assertSessionCapacity(event, lines, undefined, { excludeEntryId: waitlistOffer?.id });
// Validate each option and resolve prices / check stock.
// Options are independent of each other, so resolve them concurrently. Errors are marked
// with `.isStockError` rather than relying on shared `res.statusCode` (which would race
// across concurrent iterations) — the outer catch always responds 400 regardless.
// The kids check-in and volunteer tickets are handed out by staff, never sold.
const reserved = new Set(isStaffRole(req.user?.role) ? [] : reservedOptionIds(event));
const resolvedOptions = await Promise.all(lines.map(async (option) => {
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
if (!eventOption || reserved.has(eventOption.id)) {
throw new Error(`Option with ID ${option.eventOptionId} not found for this event`);
}
if (!option.quantity || option.quantity < 1) {
throw new Error('Quantity must be at least 1');
}
const qty = option.quantity;
// Check overall option stock
try {
const stockCheck = await checkOptionStock(eventOption, qty, waitlistOffer?.id, option.sessionId);
if (!stockCheck.available) {
const err = new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
err.isStockError = true;
throw err;
}
} catch (e) {
if (e.isStockError) throw e; // propagate stock errors
// If stock check function fails (pre-migration), continue without stock check
}
// Check variant stock (if a variant is selected)
let variantId = option.variantId || null;
let variantPrice = null;
if (variantId && canIncludeVariants) {
const variant = (eventOption.variants || []).find(v => v.id === variantId);
if (!variant) {
throw new Error(`Variant not found for option "${eventOption.name}"`);
}
try {
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id, option.sessionId);
if (!vStock.available) {
const err = new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
err.isStockError = true;
throw err;
}
} catch (e) {
if (e.isStockError) throw e;
}
variantPrice = variant.price; // null = use option price
}
// Resolve price: variant-level tiers take priority, then option-level tiers, then
// base prices, then the staff price for Staff and above.
let priceSnapshot = null;
let appliedTierId = null;
if (canIncludeTiers) {
const buyer = req.user || (waitlistOffer ? await prisma.user.findUnique({ where: { id: waitlistOffer.userId }, select: { id: true, role: true } }) : null);
({ priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, qty, buyer));
} else {
priceSnapshot = variantPrice !== null && variantPrice !== undefined ? variantPrice : null;
}
if (priceSnapshot === null) priceSnapshot = eventOption.price;
return { ...option, variantId, appliedTierId, priceSnapshot };
}));
// Check a coupon code before anything is saved, so a bad code doesn't leave a
// half-made registration behind. It's applied (and re-checked under a lock) below.
if (couponCode) {
const buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
await previewCoupon({ code: couponCode, eventId, user: buyer, lines: pendingCouponLines(event, resolvedOptions) });
}
// Check for existing non-cancelled registration for this user+event → merge instead
const existingReg = await prisma.registration.findFirst({
where: { userId, eventId, status: { not: 'cancelled' } },
include: { registrationOptions: true }
});
// Attendee names are checked against the places the registration will have after
// this purchase, counting names already given on an existing registration.
const cleanAttendees = validateAttendees(
event,
placesAfter(existingReg?.registrationOptions, resolvedOptions),
attendees,
existingReg ? await namedPlacesByKey(existingReg.id) : {}
);
if (ticketDelivery !== undefined && !TICKET_DELIVERY.includes(ticketDelivery)) {
res.status(400);
throw new Error("ticketDelivery must be 'payer' or 'each'");
}
let registration;
let isNewRegistration = false;
if (existingReg) {
// Merge: add a new price tranche per requested option into the existing registration.
// Never overwrite an existing row's priceSnapshot/quantity in place — that would blend
// tickets bought at different early-bird prices into a single (wrong) price. Each
// purchase gets its own tranche; the RegistrationOption row stays a maintained aggregate.
for (const opt of resolvedOptions) {
// Match on eventOptionId + variantId (+ session, for picked sessions) for correct row
const existing = existingReg.registrationOptions.find(
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) && (ro.sessionId || null) === (opt.sessionId || null)
);
if (existing) {
await prisma.$transaction([
prisma.registrationOptionTranche.create({
data: {
id: uuidv4(),
registrationOptionId: existing.id,
quantity: opt.quantity,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
}
}),
prisma.registrationOption.update({
where: { id: existing.id },
data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null, ...(existing.sessionId ? {} : { sessionIds: mergeSessionIds(existing.sessionIds, opt.sessionIds) }) }
})
]);
} else {
await prisma.registrationOption.create({
data: {
id: uuidv4(),
registrationId: existingReg.id,
eventOptionId: opt.eventOptionId,
quantity: opt.quantity,
variantId: opt.variantId || null,
sessionId: opt.sessionId || null,
sessionIds: opt.sessionIds || [],
appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot,
tranches: {
create: [{
id: uuidv4(),
quantity: opt.quantity,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
}]
}
}
});
}
}
// Only downgrade from paid if the newly added items actually cost something
const newItemsCost = resolvedOptions.reduce((sum, opt) => {
return sum + ((opt.priceSnapshot || 0) * (opt.quantity || 0));
}, 0);
if (existingReg.status === 'paid' && newItemsCost > 0) {
await prisma.registration.update({ where: { id: existingReg.id }, data: { status: 'partial_paid', updatedAt: new Date() } });
} else {
await prisma.registration.update({ where: { id: existingReg.id }, data: { updatedAt: new Date() } });
}
registration = await prisma.registration.findUnique({
where: { id: existingReg.id },
include: {
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } }
}
});
} else {
isNewRegistration = true;
// Create new registration
const registrationId = uuidv4();
registration = await prisma.registration.create({
data: {
id: registrationId,
userId,
eventId,
updatedAt: new Date(),
registrationOptions: {
create: resolvedOptions.map(option => ({
id: uuidv4(),
eventOptionId: option.eventOptionId,
quantity: option.quantity,
variantId: option.variantId || null,
sessionId: option.sessionId || null,
sessionIds: option.sessionIds || [],
appliedTierId: option.appliedTierId || null,
priceSnapshot: option.priceSnapshot,
tranches: {
create: [{
id: uuidv4(),
quantity: option.quantity,
priceSnapshot: option.priceSnapshot,
appliedTierId: option.appliedTierId || null,
}]
}
}))
}
},
include: {
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } }
}
});
}
if (cleanAttendees.length > 0) {
await createAttendees(registration.id, cleanAttendees);
queueConsentRequests(registration.id);
}
if (waitlistOffer) {
await prisma.waitlistEntry.update({
where: { id: waitlistOffer.id },
data: { status: 'claimed', claimedAt: new Date(), registrationId: registration.id, offerTokenHash: null },
});
}
if (ticketDelivery) {
await prisma.registration.update({ where: { id: registration.id }, data: { ticketDelivery } });
}
// Bundle deals, then the coupon (or re-work an existing one over the merged items).
let couponError = null;
const hasDiscount = (await recalculateRegistrationDiscount(registration.id)) > 0;
if (couponCode) {
try {
await applyCouponToRegistration({ registrationId: registration.id, code: couponCode });
} catch (e) {
// Lost a race for the last use since the check above — keep the registration.
if (!e.isCouponError) throw e;
couponError = e.message;
}
}
if (couponCode || hasDiscount || !isNewRegistration || cleanAttendees.length > 0 || ticketDelivery) {
registration = await prisma.registration.findUnique({
where: { id: registration.id },
include: {
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
bundleApplications: BUNDLE_APPS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
}
});
}
// If total due is zero, mark as paid and generate/update tickets if no required form
let freeTicketMockReq = null, freeTicketMockRes = null;
try {
const totalDue = computeRegistrationTotalDue(registration, new Date());
if (totalDue === 0) {
// Mark paid (no-op if already paid)
if (registration.status !== 'paid') {
await prisma.registration.update({ where: { id: registration.id }, data: { status: 'paid', updatedAt: new Date() } });
}
// check if event has a required form
let hasRequiredForm = false;
try {
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } });
hasRequiredForm = !!(form && form.isRequired);
} catch {}
if (!hasRequiredForm) {
// Same generator as paid registrations: a ticket per named attendee, and one
// shared quantity ticket for the rest of each line.
await generateTicketsForRegistration(registration.id);
// Capture for chained email — tickets sent after registration confirmation
freeTicketMockReq = { user: { id: registration.userId }, body: { registrationId: registration.id } };
freeTicketMockRes = { status: () => freeTicketMockRes, json: () => {} };
}
// reflect status change in the returned object
registration.status = 'paid';
}
} catch (e) {
// ignore errors in auto-generation path; manual generation remains available
}
// Fire-and-forget: registration email first, then tickets (guarantees order)
const { sendRegistrationEmails, sendRegistrationUpdatedEmails } = require('../utils/notifications');
const _regId = registration.id;
const _freeTicketReq = freeTicketMockReq;
const _freeTicketRes = freeTicketMockRes;
(async () => {
try {
if (isNewRegistration) {
await sendRegistrationEmails(_regId);
} else {
await sendRegistrationUpdatedEmails(_regId);
}
} catch (e) { console.error('Failed to send registration emails:', e); }
if (_freeTicketReq) {
try { await emailTickets(_freeTicketReq, _freeTicketRes); } catch (e) { console.error('Error emailing tickets for free registration:', e); }
}
})();
// Funnel: this registration came from a tracked (cookie-consented) visit.
if (analyticsSessionId) {
const { recordSubmit } = require('../utils/analytics');
recordSubmit(registration, analyticsSessionId).catch(e => console.warn('[analytics] submit not recorded:', e?.message || e));
}
res.status(201).json({ ...attachComputedTotals(registration), ...(couponError ? { couponError } : {}) });
const actor = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
const { registration, couponError } = await registrationService.createRegistration(req.body || {}, actor);
res.status(201).json({ ...registration, ...(couponError ? { couponError } : {}) });
} catch (error) {
res.status(400).json({ message: error.message });
}
@@ -827,71 +326,7 @@ const updateRegistrationStatus = async (req, res) => {
// @access Private
const cancelRegistration = async (req, res) => {
try {
const registration = await prisma.registration.findUnique({
where: { id: req.params.id },
include: { event: { select: { endDate: true } } }
});
if (!registration) {
res.status(404);
throw new Error('Registration not found');
}
// Check if user is authorized to cancel this registration
if (registration.userId !== req.user.id && req.user.role !== 'admin') {
res.status(403);
throw new Error('Not authorized to cancel this registration');
}
// If event is in the past, block self-cancellation (mirrors the edit-block above)
if (req.user.role !== 'admin' && registration.event?.endDate && new Date(registration.event.endDate).getTime() < Date.now()) {
res.status(400);
throw new Error('This event has already ended; registration can no longer be cancelled');
}
await assertEventOpen(registration.eventId, res);
// Check if registration can be cancelled
if (registration.status === 'cancelled') {
res.status(400);
throw new Error('Registration is already cancelled');
}
// Non-admins cannot cancel a registration that has payments against it
if (req.user.role !== 'admin') {
const paymentCount = await prisma.payment.count({
where: { registrationId: registration.id, amount: { gt: 0 } }
});
if (paymentCount > 0) {
res.status(403);
throw new Error('This registration has payments recorded against it and cannot be self-cancelled. Please contact the organisation for assistance.');
}
}
const updatedRegistration = await prisma.registration.update({
where: { id: req.params.id },
data: {
status: 'cancelled',
updatedAt: new Date()
}
});
await releaseRegistrationCoupons(req.params.id);
queueWaitlist(registration.eventId);
// Only log when staff cancels on someone else's behalf — a routine self-service
// cancellation isn't an admin action worth cluttering the audit trail with.
if (registration.userId !== req.user.id) {
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'registration_cancelled',
targetType: 'Registration',
targetId: req.params.id,
metadata: { registrationOwnerId: registration.userId },
ip: getClientIp(req),
});
}
const updatedRegistration = await registrationService.cancelRegistration(req.params.id, req.user, getClientIp(req));
res.json({ message: 'Registration cancelled', registration: updatedRegistration });
} catch (error) {
res.status(400).json({ message: error.message });
@@ -2153,7 +1588,6 @@ async function syncStatusAfterDiscount(registrationId) {
return status;
}
const isStaffRole = (role) => ['admin', 'supervisor', 'staff'].includes(role);
// @desc Apply a coupon code to an existing registration
// @route POST /api/registrations/:id/coupon
+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');
+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) });
}
};
+18 -452
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) });
}
};
+26 -1800
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -15,7 +15,7 @@
* 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';
@@ -105,7 +105,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}"`);
@@ -116,7 +116,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) {
+349
View File
@@ -0,0 +1,349 @@
/**
* Accounts: sign up, log in (with the 2FA code step) and changing your own details (1.51.2).
* Moved from userController.js so /api/users/* (v1) and /api/v2/auth/*, /api/v2/me share it.
*/
import { v4 as uuidv4 } from 'uuid';
import axios from 'axios';
import { prisma } from '../v2/legacy';
import { fail, ServiceError } from './errors';
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
const auth = require('../config/auth') as {
generateToken(id: string, role: string, tokenVersion: number): string;
hashPassword(pw: string): Promise<string>;
comparePassword(pw: string, hash: string): Promise<boolean>;
};
const twoFactor = require('../utils/twoFactor') as {
createChallenge(user: any, purpose: string, method: string): Promise<{ challengeId: string; method: string; destination: string | null }>;
verifyChallenge(challengeId: string, code: string, purpose: string): Promise<{ userId: string; method: string }>;
issueTrustedDevice(userId: string, userAgent?: string): Promise<string>;
isTrustedDevice(userId: string, deviceToken?: string): Promise<boolean>;
clearTrustedDevices(userId: string): Promise<unknown>;
requiresTwoFactor(user: { role: string }): boolean;
enforcementMethod(user: any): Promise<string | null>;
};
const { logSecurityEvent } = require('../utils/securityEvents') as { logSecurityEvent(e: object): Promise<unknown> };
const { getSettingSync } = require('../utils/settingsCache') as { getSettingSync(key: string, fallback: string): string };
const whatsapp = () => require('../utils/whatsapp') as { normalizeZAPhone(p: unknown): string | null; isValidZAPhone(p: unknown): boolean };
/** Who is asking, for security notices and the activity log. */
export type RequestInfo = { ip?: string; userAgent?: string };
// Org name for email subjects — falls back to Cross Code (the platform vendor)
// rather than any specific customer's name when unconfigured.
export function getOrgName(): string {
return getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
}
const PRIVATE_IP_RE = /^(::1|::ffff:127\.|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/;
const frontendUrl = () => (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
/**
* Fire-and-forget: create a 24h activation token and deliver it to an inactive account — by
* email if it has a real (non-guest) address, otherwise by WhatsApp if it has a phone number.
* Used when a login hits an inactive account, and when staff create an account for someone.
*/
export async function sendActivationLink(user: { id: string; name?: string | null; email?: string | null; phoneNumber?: string | null }): Promise<void> {
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);
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 activationUrl = `${frontendUrl()}/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: any) => 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: any) => console.warn('[activation WA] Failed:', e?.message || e));
}
} catch (e: any) {
console.warn('[activation token] Failed to create activation token:', e?.message || e);
}
}
// Fire-and-forget: a login notice with the approximate place it came from.
async function sendLoginNotification(user: any, info: RequestInfo): Promise<void> {
try {
const ip = info.ip || 'unknown';
const userAgent = info.userAgent || '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: any) {
console.warn('[login notification] Failed:', e?.message || e);
}
}
/** Fire-and-forget: the welcome email, with the next few upcoming events. */
export async function sendWelcomeEmail(user: any): Promise<void> {
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 { 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: any) {
console.warn('[welcome email] Failed:', e?.message || e);
}
}
// Fields the login flow needs once the password (and any 2FA code) has checked out.
export 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,
} as const;
type LoginUser = { id: string; name: string; email: string; role: string; tokenVersion: number; newsAskedAt: Date | null; noticeVersionAccepted: number | null } & Record<string, unknown>;
export type Session = {
id: string; name: string; email: string; role: string; token: string;
newsPromptPending: boolean; noticePromptPending: false | 'first' | 'updated'; deviceToken?: string;
};
/**
* Final step of a successful login (after the password, and the 2FA code when required):
* notify, record the activity, and issue the session token.
*/
export async function completeLogin(user: LoginUser, info: RequestInfo, extra: { deviceToken?: string } = {}): Promise<Session> {
const noticePromptPending = await (require('../utils/privacyNotice') as { noticePromptPending(u: object): Promise<false | 'first' | 'updated'> }).noticePromptPending(user).catch(() => false as const);
// 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(() => {});
sendLoginNotification(user, info).catch(() => {});
logSecurityEvent({ userId: user.id, type: 'login', ip: info.ip, userAgent: info.userAgent }).catch(() => {});
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
token: auth.generateToken(user.id, user.role, user.tokenVersion),
// POPIA: ask about News & announcements once (NewsPrompt on the frontend), and to accept a
// privacy notice version marked as a significant change (NoticePrompt).
newsPromptPending: !user.newsAskedAt,
noticePromptPending,
...extra,
};
}
export type SignUpInput = { name: string; email: string; password: string; phoneNumber?: string | null; notificationPreference?: string; newsOptIn?: boolean };
/** A new account (signed in straight away), with the news choice and privacy notice recorded. */
export async function signUp(input: SignUpInput, info: RequestInfo) {
const { name, email, password, phoneNumber, notificationPreference, newsOptIn } = input;
const { normalizeZAPhone, isValidZAPhone } = whatsapp();
const phone = normalizeZAPhone(phoneNumber) || (phoneNumber ? phoneNumber.replace(/\D/g, '') || null : null);
// WhatsApp needs a valid phone number; otherwise quietly use email
const allowedPrefs = ['email', 'whatsapp', 'both'];
let pref = allowedPrefs.includes(String(notificationPreference)) ? String(notificationPreference) : 'email';
if ((pref === 'whatsapp' || pref === 'both') && !isValidZAPhone(phoneNumber)) pref = 'email';
const userExists = await prisma.user.findFirst({ where: { OR: [{ email }, ...(phone ? [{ phoneNumber: phone }] : [])] } });
if (userExists) fail(409, 'User already exists', undefined, { v1Status: 400 });
const user = await prisma.user.create({
data: { id: uuidv4(), name, email, password: await auth.hashPassword(password), phoneNumber: phone, notificationPreference: pref as 'email' | 'whatsapp' | 'both', updatedAt: new Date() },
});
// 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: info.ip, answered: true, logAll: true },
).catch((e: any) => 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: info.ip })
.catch((e: any) => console.warn('[signup] notice acceptance not saved:', e?.message || e));
sendWelcomeEmail(user).catch(() => {});
return { id: user.id, name: user.name, email: user.email, role: user.role, token: auth.generateToken(user.id, user.role, user.tokenVersion) };
}
export type LoginResult =
| { status: 'signed_in'; session: Session }
| { status: 'code_required'; challenge: { challengeId: string; method: string; destination: string | null }; enrolling: boolean };
const MAX_ATTEMPTS = 5;
const LOCK_TIME = 5 * 60 * 1000;
/**
* Checks an email (or phone number) and password. Five wrong passwords lock the account for
* five minutes. With 2FA (or when the role needs it), a code is sent instead of a token.
*/
export async function logIn(input: { identifier: string; password: string; deviceToken?: string }, info: RequestInfo): Promise<LoginResult> {
const rawInput = String(input.identifier || '');
// SA local format (0xx) → international (27xx)
const normalizedPhone = whatsapp().normalizeZAPhone(rawInput) || rawInput.replace(/\D/g, '');
const user = await prisma.user.findFirst({ where: { OR: [{ email: rawInput }, ...(normalizedPhone ? [{ phoneNumber: normalizedPhone }] : [])] } });
if (!user) return fail(401, 'User does not exist');
if (!user.isActive) {
// Resend the activation link on each attempt, in case the earlier one expired.
await sendActivationLink(user);
if (user.email && !user.email.endsWith('@guest.local')) fail(401, 'Your account is not yet active. We\'ve sent you an email with a link to activate your account.');
if (user.phoneNumber) fail(401, 'Your account is not yet active. We\'ve sent you a WhatsApp message with a link to activate your account.');
fail(401, 'Your account has been deactivated');
}
if (user.lockUntil && user.lockUntil > new Date()) {
const minutes = Math.ceil((new Date(user.lockUntil).getTime() - Date.now()) / 60000);
fail(429, `Too many attempts. Try again in ${minutes} minute(s).`, undefined, { v1Status: 400 });
}
if (!(await auth.comparePassword(input.password, user.password))) {
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 } });
fail(401, 'Invalid email or password', undefined, { v1Status: 400 });
}
const updated = await prisma.user.update({ where: { id: user.id }, data: { failedAttempts: 0, lockUntil: null }, select: LOGIN_USER_SELECT });
// Two-factor: the password alone isn't enough unless this browser was remembered earlier.
if (updated.twoFactorEnabled && !(await twoFactor.isTrustedDevice(updated.id, input.deviceToken))) {
return { status: 'code_required', challenge: await twoFactor.createChallenge(updated, 'login', updated.twoFactorMethod || 'email'), enrolling: false };
}
// Admins and supervisors must use 2FA. Not set up yet: send a code to the first channel that
// can take one; verifying it turns 2FA on. A trusted device can't skip this.
if (!updated.twoFactorEnabled && twoFactor.requiresTwoFactor(updated)) {
const method = await twoFactor.enforcementMethod(updated);
if (method) return { status: 'code_required', challenge: await twoFactor.createChallenge(updated, 'login', method), enrolling: true };
}
return { status: 'signed_in', session: await completeLogin(updated, info) };
}
/** The second login step: the emailed or WhatsApp code for a token (and optionally remember this device). */
export async function verifyLoginCode(input: { challengeId: string; code: string; rememberDevice?: boolean }, info: RequestInfo): Promise<Session> {
const challenge = await twoFactor.verifyChallenge(input.challengeId, input.code, 'login');
let user = await prisma.user.findUnique({ where: { id: challenge.userId }, select: LOGIN_USER_SELECT });
if (!user || !user.isActive) return fail(401, '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 && twoFactor.requiresTwoFactor(user)) {
user = await prisma.user.update({ where: { id: user.id }, data: { twoFactorEnabled: true, twoFactorMethod: challenge.method as 'email' | 'whatsapp', updatedAt: new Date() }, select: LOGIN_USER_SELECT });
logSecurityEvent({ userId: user.id, type: 'two_factor_enabled', ip: info.ip, userAgent: info.userAgent }).catch(() => {});
}
const extra = input.rememberDevice === true ? { deviceToken: await twoFactor.issueTrustedDevice(user.id, info.userAgent) } : {};
return completeLogin(user, info, extra);
}
export type ProfileInput = {
name?: string; email?: string; phoneNumber?: string | null; notificationPreference?: string; homeCampusId?: string | null;
/** A new password; needs currentPassword. */
password?: string; currentPassword?: string;
};
export const PROFILE_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,
} as const;
/**
* Changes your own details. A new password needs the current one, signs out other devices'
* remembered 2FA, and sends a security notice. Returns the account and a fresh token.
*/
export async function updateProfile(userId: string, input: ProfileInput, info: RequestInfo) {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) return fail(404, 'User not found');
const { name, email, password, phoneNumber, currentPassword, notificationPreference, homeCampusId } = input;
// 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) fail(400, 'Choose one of the campuses in the list.');
newHomeCampusId = campus!.id;
}
}
// A password change needs the current password
let newHashedPassword: string | undefined;
if (typeof password === 'string' && password.trim().length > 0) {
const newPw = password.trim();
if (!currentPassword) fail(400, 'Current password is required to set a new password');
if (!(await auth.comparePassword(currentPassword!, user.password))) fail(400, 'Current password is incorrect');
if (newPw.length < 8) fail(400, 'New password must be at least 8 characters long');
newHashedPassword = await auth.hashPassword(newPw);
}
const { normalizeZAPhone, isValidZAPhone } = whatsapp();
let newPhone = user.phoneNumber;
if (phoneNumber !== undefined) newPhone = phoneNumber ? (normalizeZAPhone(phoneNumber) || phoneNumber.replace(/\D/g, '') || null) : null;
// WhatsApp needs 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.
const newTwoFactorMethod = user.twoFactorMethod === 'whatsapp' && !isValidZAPhone(newPhone) ? 'email' : user.twoFactorMethod;
const updated = await prisma.user.update({
where: { id: userId },
data: {
name: name || user.name, email: email || user.email, password: newHashedPassword ?? user.password, phoneNumber: newPhone,
notificationPreference: newPref, twoFactorMethod: newTwoFactorMethod, homeCampusId: newHomeCampusId, updatedAt: new Date(),
},
select: PROFILE_SELECT,
});
if (newHashedPassword) {
await twoFactor.clearTrustedDevices(updated.id);
const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
const content = buildPasswordChangedEmail({ name: updated.name, when: Date.now(), supportEmail });
sendMail({ to: updated.email, subject: `Your ${getOrgName()} password was changed`, ...content })
.catch((e: any) => console.warn('[email] Failed to send password changed alert:', e?.message || e));
const { waText } = require('../utils/notify');
waText(updated, content.text).catch(() => {});
logSecurityEvent({ userId: updated.id, type: 'password_changed', ip: info.ip, userAgent: info.userAgent }).catch(() => {});
}
return { user: updated, twoFactorRequired: twoFactor.requiresTwoFactor(updated), token: auth.generateToken(updated.id, updated.role, updated.tokenVersion) };
}
export { ServiceError };
+286
View File
@@ -0,0 +1,286 @@
/**
* Checking a ticket in by its QR code (1.51.2). Moved from ticketController.js so
* /api/tickets/scan-preview|scan (v1) and /api/v2/checkin/{qrCode} share it.
*
* Scanning has several normal answers besides "checked in" (already used, wrong event, consent
* missing…). They come back as an `outcome`, not thrown, so each caller can show them its way;
* only an unknown code is an error (404).
*/
import { v4 as uuidv4 } from 'uuid';
import { prisma } from '../v2/legacy';
import { fail } from './errors';
import type { Role } from './registrations';
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
const transfer = () => require('../utils/ticketTransfer') as { findTransferByOldCode(code: string): Promise<any>; transferredMessage(t: any): string };
const consent = () => require('../utils/consent') as { consentStatusForTickets(ids: string[]): Promise<Map<string, string>> };
type Session = { id: string; label?: string | null; startsAt: Date };
const sessions = () => require('../utils/sessions') as {
TIMEZONE: string;
eventSchedule(eventId: string): Promise<{ sessions: Session[] } & Record<string, unknown>>;
ticketScanSession(schedule: any, list: Session[], pinned: string | null, picked: string | null, now: Date, chosen: string[] | null): { session: Session | null; error: string | null };
sessionLabel(session: Session, list: Session[]): string;
redeemedForSession(usages: Usage[], sessionId: string): number;
};
type Usage = { id?: string; quantityRedeemed: number | null; scannedAt: string; sessionId: string | null };
export type ConsentStatus = 'not_required' | 'given' | 'pending' | 'declined' | 'missing' | string;
/** A ticket that was handed on: the old code tells the scanner who has it now. */
async function transferredOutcome(qrCode: string) {
const t = transfer();
const found = await t.findTransferByOldCode(qrCode);
return found ? { outcome: 'transferred' as const, message: t.transferredMessage(found) } : null;
}
export type PreviewResult =
| { outcome: 'transferred'; message: string }
| {
outcome: 'found';
ticket: {
id: string; quantity: number; isUsed: boolean; eventId: string; consentStatus: ConsentStatus; remaining: number;
session: { id: string; label: string } | null; sessionError: string | null;
registrationOption: { id: string | null; eventOption: { id: string | null; name: string | null }; variant: { id: string; name: string } | null };
event: { id: string; title: string }; user: { id: string; name: string }; attendee: { name: string } | null;
usages: Usage[];
};
};
/** What a scan would do, without checking anything in (the scanner's preview card). */
export async function previewScan(qrCode: string, opts: { sessionId?: string | null } = {}): Promise<PreviewResult> {
// Single JOIN query instead of 6 sequential Prisma round-trips (critical for remote DBs)
const rows = await prisma.$queryRaw<any[]>`
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" = ${qrCode}
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id, a.id
`;
if (!rows || rows.length === 0) return (await transferredOutcome(qrCode)) ?? fail(404, 'Ticket not found');
const r = rows[0];
const consentStatus = (await consent().consentStatusForTickets([r.id])).get(r.id) || 'not_required';
const usages: Usage[] = 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: { id: string; label: string } | null = null;
let sessionError: string | null = null;
if (r.scheduleType === 'sessions') {
const s = sessions();
const schedule = await s.eventSchedule(r.eventId);
const found = s.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, opts.sessionId || null, new Date(), r.chosenSessionIds);
sessionError = found.error;
if (found.session) {
session = { id: found.session.id, label: s.sessionLabel(found.session, schedule.sessions) };
remaining = Number(r.quantity) - s.redeemedForSession(usages, found.session.id);
} else {
remaining = 0;
}
}
return {
outcome: 'found',
ticket: {
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,
},
};
}
export type ScanTicket = {
id: string; quantity: number; isUsed: boolean; eventId: string; event: { title: string };
registrationOption: { eventOption: { id: string | null; name: string | null; isMainTicket: boolean | null } };
usages: Usage[];
};
export type ScanOptions = {
/** Refuse a ticket for any other event (the scanner's chosen event). */
eventId?: string | null;
/** Only tickets valid for this section. */
sectionId?: string | null;
/** Multi-day events: the session (default: today's). */
sessionId?: string | null;
/** The scanning device, so its own offline lock doesn't block it. */
deviceId?: string | null;
/** How many people to check in (default: everyone left). */
qty?: number | null;
/** Supervisors and admins: check in without parental consent (logged). */
overrideConsent?: boolean;
};
export type ScanResult =
| { outcome: 'checked_in'; message: string; session: { id: string; label: string } | null; ticketUsage: { id: string; scannedAt: Date; quantityRedeemed: number; sessionId: string | null }; ticket: ScanTicket; qtyRedeemed: number; totalRedeemed: number; remaining: number }
| { outcome: 'transferred'; message: string }
| { outcome: 'wrong_event' | 'section_not_found' | 'wrong_section' | 'no_session'; message: string; ticket: ScanTicket }
| { outcome: 'locked'; message: string }
| { outcome: 'already_used'; message: string; ticket: ScanTicket }
| { outcome: 'consent_required'; message: string; consentStatus: ConsentStatus; canOverride: boolean; ticket: ScanTicket };
/** Checks a ticket in (all of it, or `qty` people), or says why it can't be. */
export async function scanTicket(qrCode: string, actor: { id: string; role: Role }, opts: ScanOptions = {}, ip?: string): Promise<ScanResult> {
// Single JOIN query — avoids multiple sequential round-trips to the remote DB
const rows = await prisma.$queryRaw<any[]>`
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" = ${qrCode}
GROUP BY t.id, e.id, eo.id, ro.id
`;
if (!rows || rows.length === 0) return (await transferredOutcome(qrCode)) ?? fail(404, 'Ticket not found');
const r = rows[0];
const ticket: ScanTicket = {
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 || [],
};
// Ensure the ticket belongs to the event being scanned, when given
const providedEventId = String(opts.eventId || '').trim();
if (providedEventId && ticket.eventId !== providedEventId) return { outcome: 'wrong_event', message: 'Ticket belongs to a different event', ticket };
// Section checks used to happen only in the browser; enforce them here too.
const sectionId = String(opts.sectionId || '').trim() || null;
if (sectionId) {
const section = await prisma.section.findUnique({ where: { id: sectionId }, include: { allowedOptions: true } });
if (!section || section.eventId !== ticket.eventId) return { outcome: 'section_not_found', message: 'Section not found for this event', ticket };
if (!section.allowedOptions.some(o => o.eventOptionId === r.eventOptionId)) return { outcome: 'wrong_section', 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') as { blockingLock(target: object, opts: object): Promise<any> };
const lock = await blockingLock({ eventId: ticket.eventId, optionId: r.eventOptionId }, { sectionId, deviceId: opts.deviceId || null });
if (lock) {
const scope = lock.section ? `"${lock.section.name}"` : 'This event';
return { outcome: 'locked', 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.` };
}
// 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: Session | null = null;
let schedule: { sessions: Session[] } | null = null;
const s = sessions();
if (r.scheduleType === 'sessions') {
schedule = await s.eventSchedule(ticket.eventId);
const found = s.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, String(opts.sessionId || '').trim() || null, new Date(), r.chosenSessionIds);
if (!found.session) return { outcome: 'no_session', message: found.error || 'No session is open for check-in now', ticket };
session = found.session;
}
const label = session && schedule ? s.sessionLabel(session, schedule.sessions) : '';
const totalRedeemed = session
? s.redeemedForSession(ticket.usages, session.id)
: ticket.usages.reduce((sum, u) => sum + (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: s.TIMEZONE, hour: '2-digit', minute: '2-digit' }) : '';
return { outcome: 'already_used', message: `Already checked in for ${label}${at ? ` at ${at}` : ''}`, ticket };
}
return { outcome: 'already_used', message: 'Ticket has already been fully used', ticket };
}
// Youth events: no check-in without parental consent, unless a supervisor overrides.
const consentStatus = (await consent().consentStatusForTickets([ticket.id])).get(ticket.id) || 'not_required';
if (!['not_required', 'given'].includes(consentStatus)) {
const canOverride = ['admin', 'supervisor'].includes(actor.role);
if (!(opts.overrideConsent === true && canOverride)) {
const why = consentStatus === 'declined' ? 'Parental consent was declined' : consentStatus === 'pending' ? 'Parental consent is still pending' : 'Parental consent is missing';
return { outcome: 'consent_required', message: `${why} — this ticket can't be checked in.`, consentStatus, canOverride, ticket };
}
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'consent_override', targetType: 'Ticket', targetId: ticket.id, metadata: { consentStatus, eventId: ticket.eventId }, ip });
}
// How many to check in this scan (defaults to all remaining)
const requestedQty = Math.trunc(Number(opts.qty)) || remaining;
const qtyToRedeem = Math.min(Math.max(1, requestedQty), remaining);
const newTotalRedeemed = totalRedeemed + qtyToRedeem;
const newRemaining = (ticket.quantity || 1) - newTotalRedeemed;
// The usage insert and the ticket's status don't depend on each other
const [ticketUsage] = await Promise.all([
prisma.ticketUsage.create({
data: { id: uuidv4(), ticketId: ticket.id, scannedById: actor.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 : newRemaining <= 0, updatedAt: new Date() } }),
]);
// Only Main Tickets trigger a check-in notification
if (ticket.registrationOption.eventOption.isMainTicket) {
(require('../utils/notifications') as { sendCheckInEmails(id: string, qty: number, total: number, left: number): Promise<unknown> })
.sendCheckInEmails(ticket.id, qtyToRedeem, newTotalRedeemed, newRemaining)
.catch(e => console.error('Failed to send check-in notification:', e));
}
return {
outcome: 'checked_in',
message: `Ticket scanned successfully${session ? ` for ${label}` : ''} (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
session: session ? { id: session.id, label } : null,
ticketUsage,
ticket,
qtyRedeemed: qtyToRedeem,
totalRedeemed: newTotalRedeemed,
remaining: newRemaining,
};
}
/** The HTTP status and body v1 (/api/tickets/scan/:qrCode) has always answered for each outcome. */
export function v1ScanResponse(result: ScanResult): { status: number; body: Record<string, unknown> } {
const { outcome, ...rest } = result;
switch (outcome) {
case 'checked_in': return { status: 200, body: rest };
case 'transferred': return { status: 410, body: { ...rest, code: 'TICKET_TRANSFERRED' } };
case 'locked': return { status: 423, body: { ...rest, code: 'SCAN_LOCKED' } };
case 'no_session': return { status: 400, body: { ...rest, code: 'NO_SESSION' } };
case 'already_used': return { status: 403, body: { ...rest, remaining: 0 } };
case 'consent_required': return { status: 403, body: { ...rest, code: 'CONSENT_REQUIRED' } };
default: return { status: 400, body: rest };
}
}
+26
View File
@@ -0,0 +1,26 @@
/**
* An error from a service (src/services/*) with the HTTP status it means. v2 turns it into its
* error shape by the status (v2/errors.ts); v1 controllers keep answering as they always did —
* `v1Status` when v1 used a different status for it (e.g. 400 for a wrong password).
*/
export class ServiceError extends Error {
readonly statusCode: number;
/** Extra, e.g. a code the caller can switch on. */
readonly code?: string;
readonly v1Status?: number;
constructor(statusCode: number, message: string, code?: string, opts: { v1Status?: number } = {}) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.v1Status = opts.v1Status;
}
}
export const fail = (statusCode: number, message: string, code?: string, opts?: { v1Status?: number }): never => {
throw new ServiceError(statusCode, message, code, opts);
};
/** The status a v1 controller answers for an error (its old one where it differs). */
export const v1StatusOf = (error: { v1Status?: number; statusCode?: number } | null | undefined, fallback: number): number =>
error?.v1Status || error?.statusCode || fallback;
+88
View File
@@ -0,0 +1,88 @@
/**
* Paying a registration online (1.51.1). Moved from paymentController.createCheckout so
* POST /api/payments/checkout (v1) and POST /api/v2/registrations/:id/checkout share it.
*/
import { prisma } from '../v2/legacy';
import { fail, ServiceError } from './errors';
import type { Role } from './registrations';
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
const payments = require('../payments') as { MIN_ONLINE_AMOUNT: number };
const { createRegistrationCheckout } = require('../payments/checkouts') as {
createRegistrationCheckout(registrationId: string, userId: string, opts: { successUrl?: string; cancelUrl?: string; failureUrl?: string; donationAmount?: number; amount?: number | null; provider?: string | null }): Promise<{
checkoutId: string; redirectUrl: string; provider: string; amount: number; registration: { id: string; eventId: string; eventTitle: string };
}>;
};
const pricing = require('../utils/pricing') as {
refreshPricingForRegistration(id: string): Promise<{ changed: boolean }>;
computeRegistrationTotalDue(registration: any, at: Date): number;
};
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string): Promise<unknown> };
export type CheckoutOptions = {
/** Pay only this much of the balance (staff taking part payments). */
amount?: number | null;
/** An optional donation added on top (the register form's "Donation amount"). */
donationAmount?: number | null;
provider?: string | null;
successUrl?: string;
cancelUrl?: string;
failureUrl?: string;
};
export type CheckoutResult =
| { priceUpdated: true; newTotal: number; message: string }
| { priceUpdated?: false; checkoutId: string; redirectUrl: string; provider: string; amount: number; registration: { id: string; eventId: string; eventTitle: string } };
/**
* Starts an online payment for what's owed on a registration (the owner, or a supervisor or
* admin). If an early-bird price ran out since it was made, nothing is charged: the caller is
* told the new total to show first.
*/
export async function startRegistrationCheckout(registrationId: string, actor: { id: string; role: Role }, opts: CheckoutOptions = {}): Promise<CheckoutResult> {
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true, eventId: true, event: { select: { endDate: true } } } });
if (!reg) return fail(404, 'Registration not found');
const staffSide = actor.role === 'admin' || actor.role === 'supervisor';
if (reg.userId !== actor.id && !staffSide) fail(403, 'Not authorized to create checkout for this registration');
// If event is in the past, block self-service payment (mirrors the registration-edit block)
if (!staffSide && reg.event?.endDate && new Date(reg.event.endDate).getTime() < Date.now()) {
fail(400, 'This event has already ended; payment can no longer be made for this registration');
}
try {
await assertEventOpen(reg.eventId);
} catch (e: any) {
throw new ServiceError(e.message === 'Event not found' ? 404 : 400, e.message);
}
// 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 } = await pricing.refreshPricingForRegistration(registrationId);
if (changed) {
const fresh = await prisma.registration.findUniqueOrThrow({
where: { id: registrationId },
include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true },
});
const newTotal = pricing.computeRegistrationTotalDue(fresh, new Date());
const totalPaid = fresh.payments.reduce((s, p) => s + p.amount, 0);
return {
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.',
};
}
const urls = { successUrl: opts.successUrl, cancelUrl: opts.cancelUrl, failureUrl: opts.failureUrl, provider: opts.provider ?? null };
// Part of the balance (supervisor payments page)
if (opts.amount !== undefined && opts.amount !== null && !isNaN(Number(opts.amount))) {
return createRegistrationCheckout(registrationId, actor.id, { ...urls, amount: Number(opts.amount) });
}
let donation = 0;
if (opts.donationAmount !== undefined && opts.donationAmount !== null) {
donation = Math.round(Number(opts.donationAmount) * 100) / 100;
if (!(donation >= 0) || donation > 100000) fail(400, 'Invalid donation amount');
if (donation > 0 && donation < payments.MIN_ONLINE_AMOUNT) fail(400, `Minimum donation is R${payments.MIN_ONLINE_AMOUNT}`);
}
return createRegistrationCheckout(registrationId, actor.id, { ...urls, donationAmount: donation });
}
+497
View File
@@ -0,0 +1,497 @@
/**
* Making and cancelling registrations (1.51.1). Moved here from registrationController.js so
* POST /api/registrations (v1) and POST /api/v2/registrations run the same code. Errors are
* ServiceErrors carrying their HTTP status.
*/
import { v4 as uuidv4 } from 'uuid';
import { prisma } from '../v2/legacy';
import { fail, ServiceError } from './errors';
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
const { generateTicketsForRegistration } = require('../utils/ticketUtils') as { generateTicketsForRegistration(id: string): Promise<unknown> };
const pricing = require('../utils/pricing') as {
resolveOptionPrice(option: any, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
resolveVariantTierPrice(option: any, variantId: string, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
computeRegistrationTotalDue(registration: any, at: Date): number;
attachComputedTotals<T>(registration: T): T & { totalDueComputed: number };
};
const coupons = require('../utils/coupons') as {
buildCouponLines(registration: any): any[];
previewCoupon(args: { code: string; eventId: string; user: any; lines: any[] }): Promise<unknown>;
applyCouponToRegistration(args: { registrationId: string; code: string }): Promise<unknown>;
recalculateRegistrationDiscount(registrationId: string): Promise<number>;
releaseRegistrationCoupons(registrationId: string): Promise<unknown>;
};
const attendeesUtil = require('../utils/attendees') as {
lineKey(eventOptionId: string, variantId?: string | null): string;
validateAttendees(event: any, places: Record<string, number>, attendees: unknown, existing: Record<string, number>): any[];
createAttendees(registrationId: string, attendees: any[]): Promise<unknown>;
namedPlacesByKey(registrationId: string): Promise<Record<string, number>>;
};
const { findAccountByContact } = require('../utils/accountMatch') as { findAccountByContact(c: { email?: string; phone?: string }): Promise<{ user: { id: string } | null; conflict?: boolean }> };
const { ensureConsentRequests } = require('../utils/consent') as { ensureConsentRequests(registrationId: string): Promise<unknown> };
const { takenCount } = require('../utils/stock') as { takenCount(where: object, opts?: { excludeEntryId?: string | null }): Promise<number> };
const waitlist = require('../utils/waitlist') as { findOfferByToken(token: string): Promise<any>; queueWaitlist(eventId: string): void };
const { reservedOptionIds } = require('../utils/volunteers') as { reservedOptionIds(event: any): string[] };
const { BUNDLE_APPS_INCLUDE } = require('../utils/bundles') as { BUNDLE_APPS_INCLUDE: any };
const sessions = require('../utils/sessions') as {
registrationClosesAt(event: any, sessions: any[]): Date;
wholeCourseClosesAt(event: any, sessions: any[]): Date;
expandPickLines(event: any, options: any[], opts: { now: Date }): any[];
assertSessionCapacity(event: any, lines: any[], registrationId?: string, opts?: { excludeEntryId?: string }): Promise<void>;
mergeSessionIds(a: string[] | null | undefined, b: string[] | null | undefined): string[];
};
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string, res?: unknown): Promise<unknown> };
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
export type Role = 'admin' | 'supervisor' | 'staff' | 'user';
export type Actor = { id: string; role: Role; email?: string } | null;
export const TICKET_DELIVERY = ['payer', 'each'] as const;
export const isStaffRole = (role: string | undefined | null): boolean => ['admin', 'supervisor', 'staff'].includes(String(role));
// Named attendees for API responses, with whether their ticket has been issued/used.
export const ATTENDEES_INCLUDE = {
orderBy: { order: 'asc' as const },
include: {
ticket: { select: { id: true, isUsed: true } },
// Latest consent request (youth events) — it decides check-in.
consentRequests: { orderBy: { createdAt: 'desc' as const }, take: 1, select: { id: true, status: true, respondedAt: true, lastSentAt: true, textVersion: true } },
},
};
// Active coupon on a registration, for API responses (the discount itself is
// Registration.discountTotal).
export const ACTIVE_COUPONS_INCLUDE = {
where: { releasedAt: null },
select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } },
};
// Youth events: send parental consent requests for any new under-18 attendees. Runs in
// the background so a slow mail/WhatsApp send never holds up the registration.
export function queueConsentRequests(registrationId: string): void {
ensureConsentRequests(registrationId).catch((e: any) => console.error('[consent] Failed to send requests:', e?.message || e));
}
type Line = { eventOptionId: string; variantId?: string | null; sessionId?: string | null; quantity: number | string };
/**
* Places each line will have once `requested` is added to `existingLines` — used to
* check attendee names against what's actually being bought.
*/
export function placesAfter(existingLines: Line[] | undefined, requested: Line[] | undefined): Record<string, number> {
const out: Record<string, number> = {};
// Picked-session lines take no attendee names.
for (const l of [...(existingLines || []), ...(requested || [])].filter(x => !x.sessionId)) {
const k = attendeesUtil.lineKey(l.eventOptionId, l.variantId);
out[k] = (out[k] || 0) + Number(l.quantity || 0);
}
return out;
}
/**
* Coupon lines for options that haven't been saved yet, so a code can be checked
* before anything is created. Mirrors buildCouponLines for one tranche per option.
*/
export function pendingCouponLines(event: { eventOptions: { id: string }[] }, resolvedOptions: any[]): any[] {
return coupons.buildCouponLines({
registrationOptions: resolvedOptions.map(o => ({
eventOptionId: o.eventOptionId,
variantId: o.variantId || null,
quantity: o.quantity,
eventOption: event.eventOptions.find(eo => eo.id === o.eventOptionId),
tranches: [{ quantity: o.quantity, priceSnapshot: o.priceSnapshot, appliedTierId: o.appliedTierId }],
})),
});
}
/**
* Price one requested option for this buyer: early-bird tiers, then variant/option price,
* then the staff price for Staff and above. Returns { priceSnapshot, appliedTierId }.
*/
export async function priceRequestedOption(eventOption: any, variantId: string | null, qty: number, buyer: any): Promise<{ priceSnapshot: number; appliedTierId: string | null }> {
try {
const resolved = variantId
? await pricing.resolveVariantTierPrice(eventOption, variantId, qty, buyer)
: await pricing.resolveOptionPrice(eventOption, qty, buyer);
return { priceSnapshot: resolved.price, appliedTierId: resolved.tierId };
} catch {
const variant = variantId ? (eventOption.variants || []).find((v: any) => v.id === variantId) : null;
const price = variant && variant.price !== null && variant.price !== undefined ? variant.price : eventOption.price;
return { priceSnapshot: Number(price || 0), appliedTierId: null };
}
}
/**
* Check overall stock availability for an EventOption. Places held for live waitlist
* offers count as taken, except the offer being claimed (`excludeEntryId`).
*/
export async function checkOptionStock(option: { id: string; stockLimit: number }, requestedQty: number, excludeEntryId: string | null = null, sessionId: string | null = null) {
if (!option.stockLimit || option.stockLimit === 0) return { available: true, remaining: null as number | null };
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id, sessionId }, { excludeEntryId });
return { available: remaining >= requestedQty, remaining };
}
/** Check stock availability for a specific variant (same rules as checkOptionStock). */
export async function checkVariantStock(variant: { id: string; eventOptionId: string; stockLimit: number | null }, requestedQty: number, excludeEntryId: string | null = null, sessionId: string | null = null) {
if (!variant.stockLimit || variant.stockLimit === 0) return { available: true, remaining: null as number | null };
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id, sessionId }, { excludeEntryId });
return { available: remaining >= requestedQty, remaining };
}
export type RequestedOption = { eventOptionId: string; quantity: number; variantId?: string | null; sessionIds?: string[] };
export type CreateRegistrationInput = {
eventId: string;
options: RequestedOption[];
guestName?: string;
guestEmail?: string;
guestPhone?: string;
couponCode?: string;
attendees?: unknown[];
ticketDelivery?: string;
waitlistToken?: string;
analyticsSessionId?: string;
};
const FULL_INCLUDE = {
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
} as const;
/**
* Registers someone for an event (online, a guest for a free event, or with a waitlist link):
* checks the event is open, prices each line (early-bird, variant, staff price), checks stock
* and session places, adds to an existing registration for the same person and event, applies
* bundle deals and a coupon, and issues tickets straight away when nothing is owed.
*/
export async function createRegistration(input: CreateRegistrationInput, actor: Actor) {
try {
return await create(input, actor);
} catch (e: any) {
// The helpers (coupons, attendee names, session places…) throw plain Errors written for
// people; like v1, those are the caller's problem (400) unless they carry a status.
if (e instanceof ServiceError || /^P\d{4}$/.test(String(e?.code || ''))) throw e;
throw new ServiceError(e?.statusCode || 400, e?.message || 'Registration failed');
}
}
async function create(input: CreateRegistrationInput, actor: Actor) {
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken, analyticsSessionId } = input;
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } }, sessions: true },
});
if (!event) return fail(404, 'Event not found');
if (!event.isActive) return fail(400, 'Event is not active');
if (event.cashupStatus === 'closed') return fail(400, 'This event is closed and no longer accepting registrations.');
// A waitlist claim link: its token identifies the person and releases the places
// held for them. It works without logging in, the same way a consent link does.
let waitlistOffer: any = null;
if (waitlistToken) {
waitlistOffer = await waitlist.findOfferByToken(waitlistToken);
if (!waitlistOffer || waitlistOffer.eventId !== eventId) fail(400, 'This waitlist link is not valid.');
if (waitlistOffer.status !== 'offered') fail(400, `This waitlist offer has ${waitlistOffer.status === 'claimed' ? 'already been used' : 'expired'}.`);
if (new Date(waitlistOffer.offerExpiresAt) < new Date()) fail(400, 'This waitlist offer has expired.');
if (actor && actor.id !== waitlistOffer.userId) fail(403, 'This waitlist offer belongs to someone else.');
}
// Enforce auth unless event explicitly opts out
if (event.requiresAuth !== false && !actor && !waitlistOffer) fail(401, 'Authentication required for this event');
// Unauthenticated guest registration is only permitted for free events
if (!actor && !waitlistOffer) {
const requestedTotal = (options || []).reduce((sum, opt) => {
const eo = event.eventOptions.find(o => o.id === opt.eventOptionId);
return sum + ((eo?.price || 0) * (opt.quantity || 1));
}, 0);
if (requestedTotal > 0) fail(400, 'Guest registration is only available for free events. Please create an account to pay for tickets.');
}
// Resolve userId — authenticated or guest
let userId: string;
if (actor) {
userId = actor.id;
} else if (waitlistOffer) {
userId = waitlistOffer.userId;
} else {
// Guest registration: find existing user by email or create inactive placeholder
if (!guestEmail || !guestName) return fail(400, 'Name and email are required for unauthenticated registration');
const match = await findAccountByContact({ email: guestEmail, phone: guestPhone });
if (match.conflict) fail(409, 'This email and phone number belong to two different accounts. Please log in, or check your details.');
if (match.user) {
userId = match.user.id;
} else {
const newGuest = await prisma.user.create({
data: { id: uuidv4(), name: guestName, email: guestEmail, password: '', phoneNumber: guestPhone || null, isActive: false, updatedAt: new Date() },
});
userId = newGuest.id;
}
}
// Enforce registration cutoff for public registrations (multi-day events close when
// the first session starts, or later with late joining — see utils/sessions.js).
const now = new Date();
if (now >= sessions.registrationClosesAt(event, event.sessions)) fail(400, 'Registration is closed for this event');
if (!options || !Array.isArray(options) || options.length === 0) fail(400, 'At least one option must be selected');
// "Pick sessions" options become one line per chosen session; whole-course tickets
// on such an event still close when the whole course does.
const lines = sessions.expandPickLines(event, options, { now });
// (An all-sessions ticket with sessions ticked is checked per session instead.)
if (event.attendanceMode === 'pick' && lines.some(l => !l.sessionId && !l.sessionIds?.length) && now >= sessions.wholeCourseClosesAt(event, event.sessions)) {
fail(400, 'Whole-course tickets are no longer on sale — choose the sessions you want instead.');
}
await sessions.assertSessionCapacity(event, lines, undefined, { excludeEntryId: waitlistOffer?.id });
// Validate each option and resolve prices / check stock. Options are independent of each
// other, so resolve them concurrently. The kids check-in and volunteer tickets are handed
// out by staff, never sold.
const reserved = new Set(isStaffRole(actor?.role) ? [] : reservedOptionIds(event));
const buyerFor = async () => actor || (waitlistOffer ? await prisma.user.findUnique({ where: { id: waitlistOffer.userId }, select: { id: true, role: true } }) : null);
const resolvedOptions = await Promise.all(lines.map(async (option: any) => {
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
if (!eventOption || reserved.has(eventOption.id)) fail(400, `Option with ID ${option.eventOptionId} not found for this event`);
if (!option.quantity || option.quantity < 1) fail(400, 'Quantity must be at least 1');
const qty = option.quantity;
// (A failing count doesn't block the sale — only a real shortage does.)
const stockCheck = await checkOptionStock(eventOption!, qty, waitlistOffer?.id, option.sessionId).catch(() => ({ available: true, remaining: null }));
if (!stockCheck.available) fail(400, `"${eventOption!.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
const variantId: string | null = option.variantId || null;
if (variantId) {
const variant = (eventOption!.variants || []).find(v => v.id === variantId);
if (!variant) return fail(400, `Variant not found for option "${eventOption!.name}"`);
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id, option.sessionId).catch(() => ({ available: true, remaining: null }));
if (!vStock.available) fail(400, `Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
}
// Variant-level tiers take priority, then option-level tiers, then base prices, then
// the staff price for Staff and above.
const { priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, qty, await buyerFor());
return { ...option, variantId, appliedTierId, priceSnapshot: priceSnapshot ?? eventOption!.price };
}));
// Check a coupon code before anything is saved, so a bad code doesn't leave a
// half-made registration behind. It's applied (and re-checked under a lock) below.
if (couponCode) {
const buyer = actor ? { id: actor.id, role: actor.role, email: actor.email } : null;
await coupons.previewCoupon({ code: couponCode, eventId, user: buyer, lines: pendingCouponLines(event, resolvedOptions) });
}
// Check for existing non-cancelled registration for this user+event → merge instead
const existingReg = await prisma.registration.findFirst({
where: { userId, eventId, status: { not: 'cancelled' } },
include: { registrationOptions: true },
});
// Attendee names are checked against the places the registration will have after
// this purchase, counting names already given on an existing registration.
const cleanAttendees = attendeesUtil.validateAttendees(
event,
placesAfter(existingReg?.registrationOptions, resolvedOptions),
attendees,
existingReg ? await attendeesUtil.namedPlacesByKey(existingReg.id) : {},
);
if (ticketDelivery !== undefined && !(TICKET_DELIVERY as readonly string[]).includes(ticketDelivery)) fail(400, "ticketDelivery must be 'payer' or 'each'");
let registrationId: string;
let isNewRegistration = false;
if (existingReg) {
// Merge: add a new price tranche per requested option into the existing registration.
// Never overwrite an existing row's priceSnapshot/quantity in place — that would blend
// tickets bought at different early-bird prices into a single (wrong) price. Each
// purchase gets its own tranche; the RegistrationOption row stays a maintained aggregate.
for (const opt of resolvedOptions) {
// Match on eventOptionId + variantId (+ session, for picked sessions) for correct row
const existing = existingReg.registrationOptions.find(
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) && (ro.sessionId || null) === (opt.sessionId || null),
);
if (existing) {
await prisma.$transaction([
prisma.registrationOptionTranche.create({
data: { id: uuidv4(), registrationOptionId: existing.id, quantity: opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null },
}),
prisma.registrationOption.update({
where: { id: existing.id },
data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null, ...(existing.sessionId ? {} : { sessionIds: sessions.mergeSessionIds(existing.sessionIds, opt.sessionIds) }) },
}),
]);
} else {
await prisma.registrationOption.create({
data: {
id: uuidv4(),
registrationId: existingReg.id,
eventOptionId: opt.eventOptionId,
quantity: opt.quantity,
variantId: opt.variantId || null,
sessionId: opt.sessionId || null,
sessionIds: opt.sessionIds || [],
appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot,
tranches: { create: [{ id: uuidv4(), quantity: opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null }] },
},
});
}
}
// Only downgrade from paid if the newly added items actually cost something
const newItemsCost = resolvedOptions.reduce((sum, opt) => sum + ((opt.priceSnapshot || 0) * (opt.quantity || 0)), 0);
await prisma.registration.update({
where: { id: existingReg.id },
data: existingReg.status === 'paid' && newItemsCost > 0 ? { status: 'partial_paid', updatedAt: new Date() } : { updatedAt: new Date() },
});
registrationId = existingReg.id;
} else {
isNewRegistration = true;
registrationId = uuidv4();
await prisma.registration.create({
data: {
id: registrationId,
userId,
eventId,
updatedAt: new Date(),
registrationOptions: {
create: resolvedOptions.map(option => ({
id: uuidv4(),
eventOptionId: option.eventOptionId,
quantity: option.quantity,
variantId: option.variantId || null,
sessionId: option.sessionId || null,
sessionIds: option.sessionIds || [],
appliedTierId: option.appliedTierId || null,
priceSnapshot: option.priceSnapshot,
tranches: { create: [{ id: uuidv4(), quantity: option.quantity, priceSnapshot: option.priceSnapshot, appliedTierId: option.appliedTierId || null }] },
})),
},
},
});
}
if (cleanAttendees.length > 0) {
await attendeesUtil.createAttendees(registrationId, cleanAttendees);
queueConsentRequests(registrationId);
}
if (waitlistOffer) {
await prisma.waitlistEntry.update({
where: { id: waitlistOffer.id },
data: { status: 'claimed', claimedAt: new Date(), registrationId, offerTokenHash: null },
});
}
if (ticketDelivery) {
await prisma.registration.update({ where: { id: registrationId }, data: { ticketDelivery: ticketDelivery as 'payer' | 'each' } });
}
// Bundle deals, then the coupon (or re-work an existing one over the merged items).
let couponError: string | null = null;
await coupons.recalculateRegistrationDiscount(registrationId);
if (couponCode) {
try {
await coupons.applyCouponToRegistration({ registrationId, code: couponCode });
} catch (e: any) {
// Lost a race for the last use since the check above — keep the registration.
if (!e.isCouponError) throw e;
couponError = e.message;
}
}
const load = () => prisma.registration.findUniqueOrThrow({
where: { id: registrationId },
include: { ...FULL_INCLUDE, couponRedemptions: ACTIVE_COUPONS_INCLUDE, bundleApplications: BUNDLE_APPS_INCLUDE, attendees: ATTENDEES_INCLUDE },
});
let registration = await load();
// Nothing owed: mark paid and issue tickets (unless a required form comes first).
let emailTicketsAfter = false;
try {
if (pricing.computeRegistrationTotalDue(registration, new Date()) === 0) {
if (registration.status !== 'paid') {
await prisma.registration.update({ where: { id: registrationId }, data: { status: 'paid', updatedAt: new Date() } });
}
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } }).catch(() => null);
if (!(form && form.isRequired)) {
// Same generator as paid registrations: a ticket per named attendee, and one
// shared quantity ticket for the rest of each line.
await generateTicketsForRegistration(registrationId);
emailTicketsAfter = true;
}
registration = { ...registration, status: 'paid' };
}
} catch {
// ignore errors in auto-generation path; manual generation remains available
}
// Fire-and-forget: registration email first, then tickets (guarantees order)
const notifications = require('../utils/notifications') as { sendRegistrationEmails(id: string): Promise<unknown>; sendRegistrationUpdatedEmails(id: string): Promise<unknown> };
const ownerId = registration.userId;
void (async () => {
try {
if (isNewRegistration) await notifications.sendRegistrationEmails(registrationId);
else await notifications.sendRegistrationUpdatedEmails(registrationId);
} catch (e) { console.error('Failed to send registration emails:', e); }
if (emailTicketsAfter) {
try {
const { emailTickets } = require('../controllers/ticketController') as { emailTickets(req: unknown, res: unknown): Promise<unknown> };
const res: { status(): unknown; json(): void } = { status: () => res, json: () => {} };
await emailTickets({ user: { id: ownerId }, body: { registrationId } }, res);
} catch (e) { console.error('Error emailing tickets for free registration:', e); }
}
})();
// Funnel: this registration came from a tracked (cookie-consented) visit.
if (analyticsSessionId) {
const { recordSubmit } = require('../utils/analytics') as { recordSubmit(reg: unknown, sessionId: string): Promise<unknown> };
recordSubmit(registration, analyticsSessionId).catch((e: any) => console.warn('[analytics] submit not recorded:', e?.message || e));
}
return { registration: pricing.attachComputedTotals(registration), isNew: isNewRegistration, couponError };
}
/**
* Cancels a registration: the owner (not after the event, not once anything is paid) or an
* admin. Releases its coupon and offers its places to the waitlist.
*/
export async function cancelRegistration(registrationId: string, actor: { id: string; role: Role }, ip?: string) {
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: { event: { select: { endDate: true } } },
});
if (!registration) return fail(404, 'Registration not found');
if (registration.userId !== actor.id && actor.role !== 'admin') fail(403, 'Not authorized to cancel this registration');
// If event is in the past, block self-cancellation (mirrors the edit block)
if (actor.role !== 'admin' && registration.event?.endDate && new Date(registration.event.endDate).getTime() < Date.now()) {
fail(400, 'This event has already ended; registration can no longer be cancelled');
}
try {
await assertEventOpen(registration.eventId);
} catch (e: any) {
throw new ServiceError(e.message === 'Event not found' ? 404 : 400, e.message);
}
if (registration.status === 'cancelled') fail(400, 'Registration is already cancelled');
// Non-admins cannot cancel a registration that has payments against it
if (actor.role !== 'admin') {
const paymentCount = await prisma.payment.count({ where: { registrationId: registration.id, amount: { gt: 0 } } });
if (paymentCount > 0) fail(403, 'This registration has payments recorded against it and cannot be self-cancelled. Please contact the organisation for assistance.');
}
const updated = await prisma.registration.update({ where: { id: registrationId }, data: { status: 'cancelled', updatedAt: new Date() } });
await coupons.releaseRegistrationCoupons(registrationId);
waitlist.queueWaitlist(registration.eventId);
// Only log when staff cancels on someone else's behalf — a routine self-service
// cancellation isn't an admin action worth cluttering the audit trail with.
if (registration.userId !== actor.id) {
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'registration_cancelled', targetType: 'Registration', targetId: registrationId,
metadata: { registrationOwnerId: registration.userId }, ip,
});
}
return updated;
}
+42
View File
@@ -34,6 +34,48 @@ const EVENT_TYPES = [
];
const TYPES = new Set(EVENT_TYPES.map(t => t.type));
// What `data` looks like for each type — shown in Site settings → Webhooks so whoever builds the
// receiving side knows what arrives. tests/webhooks.test.js checks these match buildPayload.
const EXAMPLE_EVENT = { id: 'c1a2b3c4-0000-4000-8000-000000000001', title: 'Youth Camp 2026', startDate: '2026-12-04T16:00:00.000Z', endDate: '2026-12-06T12:00:00.000Z' };
const EXAMPLE_PERSON = { id: 'u1a2b3c4-0000-4000-8000-000000000002', name: 'Thandi Mokoena', email: 'thandi@example.com', phone: '+27821234567' };
const exampleRegistration = (status, amountPaid) => ({
registration: {
id: 'r1a2b3c4-0000-4000-8000-000000000003', status, createdAt: '2026-10-29T08:15:00.000Z', totalDue: 1200, amountPaid,
seasonPassPurchaseId: null,
items: [{ ticket: 'Camper', variant: 'Bunk bed', quantity: 2, unitPrice: 600 }],
},
event: EXAMPLE_EVENT,
person: EXAMPLE_PERSON,
});
const examplePayment = (amount) => ({
payment: { id: 'p1a2b3c4-0000-4000-8000-000000000004', amount, method: 'card', provider: 'yoco', isDonation: false, registrationId: 'r1a2b3c4-0000-4000-8000-000000000003', createdAt: '2026-10-29T08:16:00.000Z' },
event: EXAMPLE_EVENT,
person: EXAMPLE_PERSON,
});
const EXAMPLES = {
'registration.created': exampleRegistration('pending', 0),
'registration.paid': exampleRegistration('paid', 1200),
'registration.cancelled': exampleRegistration('cancelled', 0),
'payment.received': examplePayment(1200),
// Refunds are recorded as negative payments.
'payment.refunded': examplePayment(-600),
'ticket.checked_in': {
checkIn: { id: 't1a2b3c4-0000-4000-8000-000000000005', scannedAt: '2026-12-04T16:20:00.000Z', people: 1, manual: false, offline: false, session: { id: 's1a2b3c4-0000-4000-8000-000000000006', label: 'Friday evening', startsAt: '2026-12-04T16:00:00.000Z' } },
ticket: { id: 'k1a2b3c4-0000-4000-8000-000000000007', registrationOptionId: 'o1a2b3c4-0000-4000-8000-000000000008', holderName: 'Thandi Mokoena' },
event: EXAMPLE_EVENT,
person: EXAMPLE_PERSON,
},
'user.created': { person: { ...EXAMPLE_PERSON, role: 'user', createdAt: '2026-10-29T08:10:00.000Z' } },
'event.published': {
event: {
...EXAMPLE_EVENT, description: 'A weekend away for grades 8–12.', location: 'Camp Joy, Magaliesburg', price: 600,
goLiveAt: '2026-10-29T06:00:00.000Z', url: 'https://events.example.org/events/c1a2b3c4-0000-4000-8000-000000000001',
campus: { name: 'North', slug: 'north' }, series: null,
},
},
};
for (const t of EVENT_TYPES) t.example = EXAMPLES[t.type];
const RETRY_AFTER_MS = [60e3, 5 * 60e3, 30 * 60e3, 2 * 3600e3, 12 * 3600e3];
const MAX_ATTEMPTS = RETRY_AFTER_MS.length + 1;
const KEEP_DAYS = 30;
+52
View File
@@ -0,0 +1,52 @@
import type { RequestHandler } from 'express';
/**
* v1 endpoints that have a v2 replacement say so (1.51.3): a `Deprecation` header (RFC 9745,
* the date v2 covered them) and `Link: <…>; rel="successor-version"` pointing at the v2
* endpoint. v1 keeps working until 2.0; endpoints without a v2 version get no header yet.
*/
// When these v1 endpoints got their v2 replacement (1.51.3's release day).
const DEPRECATED_AT = Math.floor(Date.UTC(2026, 9, 29) / 1000);
type Successor = { method: string; v1: RegExp; v2: (m: RegExpMatchArray) => string };
const SUCCESSORS: Successor[] = [
{ method: 'GET', v1: /^\/api\/events\/?$/, v2: () => '/api/v2/events' },
{ method: 'GET', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}` },
{ method: 'GET', v1: /^\/api\/users\/profile$/, v2: () => '/api/v2/me' },
{ method: 'PUT', v1: /^\/api\/users\/profile$/, v2: () => '/api/v2/me' },
{ method: 'POST', v1: /^\/api\/users\/?$/, v2: () => '/api/v2/auth/signup' },
{ method: 'POST', v1: /^\/api\/users\/login$/, v2: () => '/api/v2/auth/login' },
{ method: 'POST', v1: /^\/api\/users\/login\/2fa$/, v2: () => '/api/v2/auth/login/code' },
{ method: 'GET', v1: /^\/api\/registrations\/myregistrations$/, v2: () => '/api/v2/me/registrations' },
{ method: 'POST', v1: /^\/api\/registrations\/?$/, v2: () => '/api/v2/registrations' },
{ method: 'DELETE', v1: /^\/api\/registrations\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/cancel` },
{ method: 'POST', v1: /^\/api\/payments\/checkout$/, v2: () => '/api/v2/registrations/{id}/checkout' },
{ method: 'GET', v1: /^\/api\/tickets\/scan-preview\/([^/]+)$/, v2: m => `/api/v2/checkin/${m[1]}` },
{ method: 'POST', v1: /^\/api\/tickets\/scan\/([^/]+)$/, v2: m => `/api/v2/checkin/${m[1]}` },
];
// GET /api/events/all isn't a single event.
const NOT_AN_EVENT_ID = new Set(['all']);
/** The v2 successor of a v1 request, if it has one. Pure; exported for tests. */
export function successorOf(method: string, path: string): string | null {
for (const s of SUCCESSORS) {
if (s.method !== method) continue;
const m = path.match(s.v1);
if (!m) continue;
if (s.v1.source.startsWith('^\\/api\\/events\\/(') && NOT_AN_EVENT_ID.has(m[1])) continue;
return s.v2(m);
}
return null;
}
export const deprecationHeaders: RequestHandler = (req, res, next) => {
const successor = successorOf(req.method, req.originalUrl.split('?')[0]);
if (successor) {
res.setHeader('Deprecation', `@${DEPRECATED_AT}`);
res.setHeader('Link', `<${successor}>; rel="successor-version"`);
}
next();
};
+105
View File
@@ -0,0 +1,105 @@
import type { ErrorRequestHandler, RequestHandler } from 'express';
import { ZodError } from 'zod';
/**
* /api/v2 errors all look the same:
* { "error": { "code": "VALIDATION_FAILED", "message": "…", "fields": { "limit": "…" } } }
* `code` is stable (clients switch on it), `message` is for people, `fields` only on validation.
*/
export type ErrorCode =
| 'VALIDATION_FAILED'
| 'UNAUTHENTICATED'
| 'FORBIDDEN'
| 'NOT_FOUND'
| 'CONFLICT'
| 'RATE_LIMITED'
| 'READ_ONLY'
| 'INTERNAL';
export const ERROR_STATUS: Record<ErrorCode, number> = {
VALIDATION_FAILED: 400,
UNAUTHENTICATED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
RATE_LIMITED: 429,
READ_ONLY: 403,
INTERNAL: 500,
};
export class ApiError extends Error {
readonly code: ErrorCode;
readonly status: number;
readonly fields?: Record<string, string>;
constructor(code: ErrorCode, message: string, fields?: Record<string, string>) {
super(message);
this.code = code;
this.status = ERROR_STATUS[code];
this.fields = fields;
}
}
export const notFound = (what = 'That'): ApiError => new ApiError('NOT_FOUND', `${what} wasn't found.`);
/** "limit" or "items.0.quantity" → the first problem with it. */
export function zodFields(err: ZodError): Record<string, string> {
const fields: Record<string, string> = {};
for (const issue of err.issues) {
const key = issue.path.length ? issue.path.join('.') : '_';
if (!fields[key]) fields[key] = issue.message;
}
return fields;
}
const STATUS_CODE: Record<number, ErrorCode> = { 400: 'VALIDATION_FAILED', 401: 'UNAUTHENTICATED', 403: 'FORBIDDEN', 404: 'NOT_FOUND', 409: 'CONFLICT', 423: 'READ_ONLY', 429: 'RATE_LIMITED' };
const PRISMA_CODE: Record<string, ErrorCode> = { P2002: 'CONFLICT', P2025: 'NOT_FOUND', P2003: 'CONFLICT' };
type LegacyError = Error & { statusCode?: number; status?: number; code?: string };
/** Turns anything thrown (ApiError, zod, Prisma, the v1 auth middleware's errors) into the v2 shape. */
export function toApiError(err: unknown, responseStatus: number): ApiError {
if (err instanceof ApiError) return err;
if (err instanceof ZodError) return new ApiError('VALIDATION_FAILED', 'Some of what was sent is not valid.', zodFields(err));
const e = (err || {}) as LegacyError;
if (typeof e.code === 'string' && PRISMA_CODE[e.code]) {
return new ApiError(PRISMA_CODE[e.code], e.code === 'P2002' ? 'A record with that value already exists.' : e.code === 'P2025' ? 'Record not found.' : 'A related record prevents this.');
}
// The shared v1 middleware (protect, staff…) sets res.status() and passes an Error;
// utils throw errors with statusCode. Those messages are written for people already.
const status = e.statusCode || e.status || (responseStatus >= 400 ? responseStatus : 500);
const code = STATUS_CODE[status];
if (code) return new ApiError(code, e.message || 'Request failed.');
return new ApiError('INTERNAL', 'Something went wrong on the server.');
}
export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
const apiErr = toApiError(err, res.statusCode);
if (apiErr.code === 'INTERNAL') console.error('[api/v2]', req.method, req.originalUrl, err);
res.status(apiErr.status).json({ error: { code: apiErr.code, message: apiErr.message, ...(apiErr.fields ? { fields: apiErr.fields } : {}) } });
};
export const notFoundHandler: RequestHandler = (_req, _res, next) => next(new ApiError('NOT_FOUND', 'There is no such API endpoint.'));
// v1-style codes from shared middleware, kept as the v2 error's `reason`.
const READ_ONLY_REASONS = new Set(['IMPERSONATION_READ_ONLY', 'LICENCE_READ_ONLY']);
/**
* Mounted in front of everything on /api/v2: middleware shared with v1 (rate limits, the
* read-only guards, the auth checks, campus refusals) answers `{ message, code? }`; on v2 that
* becomes `{ error: { code, message, reason? } }`, where `reason` is the specific old code
* (e.g. TWO_FACTOR_REQUIRED, LICENCE_READ_ONLY, OTHER_CAMPUS).
*/
export const legacyErrorsToV2: RequestHandler = (_req, res, next) => {
const json = res.json.bind(res);
res.json = (body?: unknown) => {
const b = body as { message?: unknown; code?: unknown; error?: unknown } | null;
if (res.statusCode >= 400 && b && typeof b === 'object' && !('error' in b) && typeof b.message === 'string') {
const reason = typeof b.code === 'string' ? b.code : undefined;
const code: ErrorCode = reason && READ_ONLY_REASONS.has(reason) ? 'READ_ONLY' : STATUS_CODE[res.statusCode] || (res.statusCode >= 500 ? 'INTERNAL' : 'VALIDATION_FAILED');
return json({ error: { code, message: b.message, ...(reason ? { reason } : {}) } });
}
return json(body);
};
next();
};
+95
View File
@@ -0,0 +1,95 @@
import express, { Router } from 'express';
import path from 'path';
import { mount, routes } from './route';
import { buildOpenApi } from './openapi';
import { errorHandler, notFoundHandler } from './errors';
import auth from '../middleware/authMiddleware';
// Resources: importing one registers its routes.
import './resources/events';
import './resources/me';
import './resources/registrations';
import './resources/checkin';
import './resources/auth';
const pkg = require('../../package.json') as { version: string };
/** /api/v2 — see route.ts for how endpoints are declared, openapi.ts for the docs. */
export function v2Router(): Router {
const router = express.Router();
// The API description is for the people building on it (admins), like /docs.
router.get('/openapi.json', auth.protect, auth.admin, (_req, res) => {
res.json(buildOpenApi({ version: pkg.version }));
});
mount(router, routes);
router.use(notFoundHandler);
router.use(errorHandler);
return router;
}
/**
* /docs/v2: Swagger UI (served from swagger-ui-dist, nothing from a CDN). The page itself is
* public; it asks for an admin token and uses it to load openapi.json and to try requests.
*/
export function v2DocsRouter(): Router {
const router = express.Router();
const assets = path.dirname(require.resolve('swagger-ui-dist/package.json'));
router.use('/assets', express.static(assets, { index: false, maxAge: '1d' }));
router.get('/', (_req, res) => {
res.type('html').send(DOCS_HTML);
});
return router;
}
const DOCS_HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cross Events API v2</title>
<link rel="stylesheet" href="/docs/v2/assets/swagger-ui.css">
<style>
body { margin: 0; font-family: system-ui, sans-serif; }
#login { max-width: 40rem; margin: 3rem auto; padding: 0 1rem; }
#login input { width: 100%; padding: .5rem; font: inherit; border: 1px solid #9ca3af; border-radius: 6px; box-sizing: border-box; }
#login button { margin-top: .75rem; padding: .5rem 1rem; font: inherit; background: #1d4ed8; color: #fff; border: 0; border-radius: 6px; cursor: pointer; }
#login .err { color: #b91c1c; margin-top: .5rem; }
</style>
</head>
<body>
<main id="login" hidden>
<h1>Cross Events API v2</h1>
<p>The API reference is for admins. Paste your admin token (after signing in, it's under <code>hope_events_token</code> in the browser's local storage).</p>
<label for="token">Admin token</label>
<input id="token" autocomplete="off">
<button id="go" type="button">Show the API</button>
<p class="err" id="err" role="alert"></p>
</main>
<div id="ui"></div>
<script src="/docs/v2/assets/swagger-ui-bundle.js"></script>
<script>
(function () {
var params = new URLSearchParams(location.search);
var token = params.get('token') || sessionStorage.getItem('v2docs_token') || localStorage.getItem('hope_events_token') || '';
function show(t) {
fetch('/api/v2/openapi.json', { headers: { Authorization: 'Bearer ' + t } }).then(function (r) {
if (!r.ok) return r.json().then(function (b) { throw new Error((b.error && b.error.message) || r.statusText); });
return r.json();
}).then(function (spec) {
sessionStorage.setItem('v2docs_token', t);
if (params.has('token')) history.replaceState(null, '', location.pathname);
document.getElementById('login').hidden = true;
var ui = SwaggerUIBundle({ spec: spec, dom_id: '#ui', deepLinking: true, persistAuthorization: false });
ui.preauthorizeApiKey('bearerAuth', t);
}).catch(function (e) {
document.getElementById('login').hidden = false;
document.getElementById('err').textContent = t ? 'That token did not work: ' + e.message : '';
});
}
document.getElementById('go').addEventListener('click', function () { show(document.getElementById('token').value.trim()); });
show(token);
})();
</script>
</body>
</html>`;
+32
View File
@@ -0,0 +1,32 @@
/**
* Typed doors into the JavaScript side of the backend. TypeScript infers loose types from the
* .js files (the Prisma client is built by a function, helpers have defaulted options), so v2
* code imports them from here instead. When a module is converted to TypeScript, its line here
* goes and callers import it directly.
*/
import type { PrismaClient } from '@prisma/client';
// The client in config/db.js only adds query hooks (webhooks), so its models are PrismaClient's.
export const prisma = require('../config/db') as PrismaClient;
type CampusFilter = '' | 'all' | string;
const campuses = require('../utils/campuses') as {
campusesEnabled(): Promise<boolean>;
resolveCampusFilter(value: string | undefined): Promise<CampusFilter>;
campusWhere(filter: CampusFilter, opts: { enabled: boolean; withAllCampuses?: boolean }): object;
};
export const { campusesEnabled, resolveCampusFilter, campusWhere } = campuses;
export const { publicEventWhere } = require('../utils/series') as {
publicEventWhere(now?: Date): { isActive: true; isHidden: false; goLiveAt: { lte: Date } };
};
type OptionLike = { id: string; price: number; earlyBirdTiers?: unknown[] };
export const { publicOptions } = require('../utils/volunteers') as {
publicOptions<O extends { id: string }>(event: { eventOptions?: O[]; kidsOptionId?: string | null; volunteerOptionId?: string | null }): O[];
};
export const { getEffectiveUnitPrice, computeRegistrationTotalDue } = require('../utils/pricing') as {
getEffectiveUnitPrice(option: OptionLike, referenceTime: Date | null, atTime: Date): number;
computeRegistrationTotalDue(registration: object, atTime: Date): number;
};
+107
View File
@@ -0,0 +1,107 @@
import { z } from 'zod';
import { routes as allRoutes, type AnyRoute, type Access } from './route';
import { ERROR_STATUS } from './errors';
/**
* The OpenAPI 3.1 document for /api/v2, built from the route declarations (route.ts), so it is
* always what the code does. Served at /api/v2/openapi.json, shown at /docs/v2.
*/
type JsonSchema = Record<string, unknown>;
const toSchema = (schema: z.ZodType, io: 'input' | 'output'): JsonSchema => {
const { $schema: _drop, ...rest } = z.toJSONSchema(schema, { io, unrepresentable: 'any' }) as JsonSchema;
return rest;
};
const ACCESS_TEXT: Record<Access, string> = {
public: 'Anyone.',
optional: 'Anyone; signed-in staff may see more.',
user: 'Any signed-in person.',
staff: 'Staff, supervisors and admins.',
supervisor: 'Supervisors and admins.',
admin: 'Admins only.',
};
const errorSchema = {
type: 'object',
required: ['error'],
properties: {
error: {
type: 'object',
required: ['code', 'message'],
properties: {
code: { type: 'string', enum: Object.keys(ERROR_STATUS) },
message: { type: 'string' },
fields: { type: 'object', additionalProperties: { type: 'string' }, description: 'Per-field problems (VALIDATION_FAILED only)' },
reason: { type: 'string', description: 'A more specific cause when there is one, e.g. TWO_FACTOR_REQUIRED, IMPERSONATION_ENDED, LICENCE_READ_ONLY, OTHER_CAMPUS' },
},
},
},
};
const errorResponse = (description: string) => ({ description, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } });
function parameters(def: AnyRoute) {
const out: JsonSchema[] = [];
const add = (schema: z.ZodType | undefined, where: 'path' | 'query') => {
if (!schema) return;
const json = toSchema(schema, 'input') as { properties?: Record<string, JsonSchema>; required?: string[] };
for (const [name, prop] of Object.entries(json.properties || {})) {
const { description, ...propSchema } = prop as JsonSchema & { description?: string };
out.push({ name, in: where, required: where === 'path' || (json.required || []).includes(name), ...(description ? { description } : {}), schema: propSchema });
}
};
add(def.params, 'path');
add(def.query, 'query');
return out;
}
export function buildOpenApi(opts: { version: string; serverUrl?: string; list?: AnyRoute[] }) {
const list = opts.list ?? allRoutes;
const paths: Record<string, Record<string, unknown>> = {};
for (const def of list) {
const path = def.path.replace(/:(\w+)/g, '{$1}');
const secured = def.access !== 'public' && def.access !== 'optional';
paths[path] ||= {};
paths[path][def.method] = {
tags: [def.tag],
summary: def.summary,
description: [def.description, `**Who:** ${ACCESS_TEXT[def.access]}`].filter(Boolean).join('\n\n'),
operationId: `${def.method}${path.replace(/[{}]/g, '').split('/').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('')}`,
...(secured ? { security: [{ bearerAuth: [] }] } : def.access === 'optional' ? { security: [{}, { bearerAuth: [] }] } : {}),
parameters: parameters(def),
...(def.body ? { requestBody: { required: true, content: { 'application/json': { schema: toSchema(def.body, 'input') } } } } : {}),
responses: {
[String(def.status ?? 200)]: { description: 'OK', content: { 'application/json': { schema: toSchema(def.response, 'output') } } },
...(def.params || def.query || def.body ? { 400: errorResponse('Validation failed (VALIDATION_FAILED)') } : {}),
...(secured ? { 401: errorResponse('Not signed in (UNAUTHENTICATED)') } : {}),
...(secured && def.access !== 'user' ? { 403: errorResponse('Not allowed for this role (FORBIDDEN)') } : {}),
...(def.params ? { 404: errorResponse('Not found (NOT_FOUND)') } : {}),
},
};
}
return {
openapi: '3.1.0',
info: {
title: 'Cross Events API',
version: opts.version,
description: [
'Version 2 of the Cross Events API. Every response is JSON:',
'- a single item is `{ "data": { … } }`;',
'- a list is `{ "data": [ … ], "meta": { "page", "limit", "total", "pages" } }`, paged with `?page=` and `?limit=` (at most 100);',
'- an error is `{ "error": { "code", "message", "fields"? } }` with a stable `code`.',
'',
'Dates are ISO 8601 in UTC; amounts are in rand. Sign in through the app and send the token as `Authorization: Bearer <token>`.',
'Version 1 (`/api/…`) keeps working while the app moves over; it will be removed in 2.0.',
].join('\n'),
},
servers: [{ url: opts.serverUrl ?? '/api/v2' }],
tags: Array.from(new Set(list.map(r => r.tag))).map(name => ({ name })),
paths,
components: {
securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } },
schemas: { Error: errorSchema },
},
};
}
+28
View File
@@ -0,0 +1,28 @@
import { z } from 'zod';
/** `?page=2&limit=25` on every v2 list (limit 1–100, default 25). */
export const pageQuery = z.object({
page: z.coerce.number().int().min(1).default(1).describe('Page number, from 1'),
limit: z.coerce.number().int().min(1).max(100).default(25).describe('Items per page (at most 100)'),
});
export type PageQuery = z.infer<typeof pageQuery>;
export const pageMeta = z.object({
page: z.number().int(),
limit: z.number().int(),
total: z.number().int().describe('Items across all pages'),
pages: z.number().int().describe('Number of pages (at least 1)'),
});
/** The response shape of a v2 list: `{ data: [...], meta: { page, limit, total, pages } }`. */
export const paged = <T extends z.ZodType>(item: T) => z.object({ data: z.array(item), meta: pageMeta });
/** The response shape of a single v2 resource: `{ data: {...} }`. */
export const single = <T extends z.ZodType>(item: T) => z.object({ data: item });
/** Prisma's skip/take for a page. */
export const skipTake = ({ page, limit }: PageQuery) => ({ skip: (page - 1) * limit, take: limit });
export function pageOf<T>(data: T[], total: number, { page, limit }: PageQuery) {
return { data, meta: { page, limit, total, pages: Math.max(1, Math.ceil(total / limit)) } };
}
+105
View File
@@ -0,0 +1,105 @@
import { z } from 'zod';
import type { Request } from 'express';
import { defineRoute } from '../route';
import { single } from '../paging';
import { id } from '../schemas';
import auth from '../../middleware/authMiddleware';
import { signUp, logIn, verifyLoginCode, type Session } from '../../services/auth';
const requestInfo = (req: Request) => {
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
return { ip: getClientIp(req), userAgent: req.headers['user-agent'] };
};
const session = z.object({
status: z.literal('signed_in'),
token: z.string().describe('Send as `Authorization: Bearer <token>`'),
user: z.object({ id, name: z.string(), email: z.string(), role: z.enum(['admin', 'supervisor', 'staff', 'user']) }),
newsPromptPending: z.boolean().describe('Ask once whether they want News & announcements'),
noticePromptPending: z.union([z.literal(false), z.enum(['first', 'updated'])]).describe('A privacy notice they must accept: never accepted (first) or a newer version (updated)'),
deviceToken: z.string().optional().describe('With rememberDevice: send it with the next login to skip the code on this device'),
});
const toSession = (s: Session): z.input<typeof session> => ({
status: 'signed_in',
token: s.token,
user: { id: s.id, name: s.name, email: s.email, role: s.role as 'admin' | 'supervisor' | 'staff' | 'user' },
newsPromptPending: s.newsPromptPending,
noticePromptPending: s.noticePromptPending,
...(s.deviceToken ? { deviceToken: s.deviceToken } : {}),
});
export const signup = defineRoute({
method: 'post',
path: '/auth/signup',
tag: 'Accounts',
summary: 'Create an account',
description: 'Signs the new account in straight away. `acceptTerms` must be true (the Terms of Use and Privacy Policy); `newsOptIn` is the unticked "News & announcements" box.',
access: 'public',
status: 201,
body: z.object({
name: z.string().trim().min(1).max(200),
email: z.email().max(200),
password: z.string().min(8).max(200),
phone: z.string().trim().max(40).optional(),
notificationPreference: z.enum(['email', 'whatsapp', 'both']).optional().describe('WhatsApp needs a valid South African number; otherwise email is used'),
newsOptIn: z.boolean().optional(),
acceptTerms: z.literal(true),
}),
response: single(session),
async handler({ body, req }) {
const made = await signUp({ name: body.name, email: body.email, password: body.password, phoneNumber: body.phone, notificationPreference: body.notificationPreference, newsOptIn: body.newsOptIn }, requestInfo(req));
return { data: { status: 'signed_in' as const, token: made.token, user: { id: made.id, name: made.name, email: made.email, role: made.role }, newsPromptPending: false, noticePromptPending: false as const } };
},
});
export const login = defineRoute({
method: 'post',
path: '/auth/login',
tag: 'Accounts',
summary: 'Log in',
description: [
'With an email address or phone number. Five wrong passwords lock the account for five minutes (`RATE_LIMITED`).',
'With two-factor authentication (always for supervisors and admins), the answer is `code_required`: a code was sent by email or WhatsApp — finish with `POST /auth/login/code`.',
].join('\n\n'),
access: 'public',
middleware: [auth.loginLimiter],
body: z.object({
identifier: z.string().trim().min(1).max(200).describe('Email address or phone number'),
password: z.string().min(1).max(200),
deviceToken: z.string().max(500).optional().describe('From an earlier login with rememberDevice'),
}),
response: single(z.discriminatedUnion('status', [
session,
z.object({
status: z.literal('code_required'),
challengeId: z.string(),
method: z.enum(['email', 'whatsapp']),
destination: z.string().nullable().describe('Where the code went, partly hidden'),
enrolling: z.boolean().describe('true = this turns two-factor on (required for the role)'),
}),
])),
async handler({ body, req }) {
const result = await logIn(body, requestInfo(req));
if (result.status === 'signed_in') return { data: toSession(result.session) };
return { data: { status: 'code_required' as const, challengeId: result.challenge.challengeId, method: result.challenge.method as 'email' | 'whatsapp', destination: result.challenge.destination ?? null, enrolling: result.enrolling } };
},
});
export const loginCode = defineRoute({
method: 'post',
path: '/auth/login/code',
tag: 'Accounts',
summary: 'Finish logging in with the code',
access: 'public',
middleware: [auth.loginLimiter],
body: z.object({
challengeId: z.string().min(1).max(100),
code: z.string().trim().min(4).max(12),
rememberDevice: z.boolean().optional().describe('Skip the code on this device next time (30 days)'),
}),
response: single(session),
async handler({ body, req }) {
return { data: toSession(await verifyLoginCode(body, requestInfo(req))) };
},
});
+133
View File
@@ -0,0 +1,133 @@
import { z } from 'zod';
import type { Request } from 'express';
import { defineRoute } from '../route';
import { ApiError } from '../errors';
import { single } from '../paging';
import { id } from '../schemas';
import { previewScan, scanTicket, type ScanTicket } from '../../services/checkin';
const OUTCOMES = ['checked_in', 'already_used', 'consent_required', 'wrong_event', 'wrong_section', 'section_not_found', 'no_session', 'locked', 'transferred'] as const;
const qrParams = z.object({ qrCode: z.string().min(1).max(200).describe('The code in the ticket\'s QR') });
/**
* Campus-scoped staff may only check in tickets for their campuses' events (and church-wide
* ones) — the same rule as v1's scopeEvent({ resolve: 'ticketQr', nameCampus: true }).
*/
async function assertTicketCampus(req: Request, qrCode: string) {
const cs = require('../../utils/campusScope') as {
scopeFor(req: unknown): Promise<object | null>;
eventOf(resolve: string, value: string): Promise<{ campusId: string | null; campusName?: string | null } | null>;
canView(scope: object, campusId: string | null): boolean;
};
const scope = await cs.scopeFor(req);
if (!scope) return;
const ev = await cs.eventOf('ticketQr', qrCode);
if (ev && !cs.canView(scope, ev.campusId)) throw new ApiError('FORBIDDEN', `This ticket is for an event at ${ev.campusName || 'another campus'}.`);
}
const ticketInfo = z.object({
id,
eventId: id,
eventTitle: z.string().nullable(),
ticketName: z.string().nullable().describe('The ticket option, e.g. "Adult"'),
quantity: z.number().int().describe('People on this ticket'),
});
const toTicketInfo = (t: ScanTicket) => ({ id: t.id, eventId: t.eventId, eventTitle: t.event.title ?? null, ticketName: t.registrationOption.eventOption.name ?? null, quantity: t.quantity });
const sessionRef = z.object({ id, label: z.string() });
export const preview = defineRoute({
method: 'get',
path: '/checkin/:qrCode',
tag: 'Check-in',
summary: 'Look a ticket up before checking it in',
description: 'Who it is for, how many are left to check in (for today\'s session on multi-day events, or `sessionId`), and parental consent. Nothing is changed.',
access: 'staff',
campusScope: 'event:ticketQr:view (assertTicketCampus)',
params: qrParams,
query: z.object({ sessionId: id.optional().describe('Multi-day events: the session (default: today\'s)') }),
response: single(z.object({
outcome: z.enum(['found', 'transferred']),
message: z.string().nullable().describe('For a transferred ticket: who has it now'),
ticket: z.object({
id,
eventId: id,
eventTitle: z.string().nullable(),
ticketOptionId: id.nullable().describe('For section checks'),
ticketName: z.string().nullable(),
variant: z.string().nullable(),
attendee: z.string().nullable().describe('The attendee named on it, if any'),
bookedBy: z.string().nullable().describe('Who registered'),
quantity: z.number().int(),
remaining: z.number().int().describe('Still to check in (this session, on multi-day events)'),
session: sessionRef.nullable(),
sessionError: z.string().nullable().describe('Why no session is open for check-in now'),
consentStatus: z.string().describe('not_required, given, pending, declined or missing'),
}).nullable(),
})),
async handler({ params, query, req }) {
await assertTicketCampus(req, params.qrCode);
const result = await previewScan(params.qrCode, { sessionId: query.sessionId ?? null });
if (result.outcome === 'transferred') return { data: { outcome: 'transferred' as const, message: result.message, ticket: null } };
const t = result.ticket;
return {
data: {
outcome: 'found' as const,
message: null,
ticket: {
id: t.id, eventId: t.eventId, eventTitle: t.event.title ?? null, ticketName: t.registrationOption.eventOption.name ?? null,
ticketOptionId: t.registrationOption.eventOption.id ?? null, variant: t.registrationOption.variant?.name ?? null,
attendee: t.attendee?.name ?? null, bookedBy: t.user.name ?? null, quantity: t.quantity,
remaining: t.remaining, session: t.session, sessionError: t.sessionError, consentStatus: t.consentStatus,
},
},
};
},
});
export const scan = defineRoute({
method: 'post',
path: '/checkin/:qrCode',
tag: 'Check-in',
summary: 'Check a ticket in',
description: [
'Checks in everyone left on the ticket, or `people`. Every normal result is a 200 with an `outcome`:',
'`checked_in`, `already_used`, `consent_required` (a supervisor can resend with `overrideConsent`, which is logged), `wrong_event`, `wrong_section`, `section_not_found`, `no_session` (multi-day: nothing open now), `locked` (offline-locked to another device) or `transferred` (handed on; `message` says to whom).',
'An unknown code is `NOT_FOUND`.',
].join('\n\n'),
access: 'staff',
campusScope: 'event:ticketQr:view (assertTicketCampus)',
params: qrParams,
body: z.object({
eventId: id.optional().describe('Refuse tickets for any other event'),
sectionId: id.optional().describe('Only tickets valid for this section'),
sessionId: id.optional().describe('Multi-day events: the session (default: today\'s)'),
people: z.number().int().min(1).max(10000).optional().describe('How many to check in (default: everyone left)'),
overrideConsent: z.boolean().optional(),
deviceId: z.string().max(200).optional().describe('This scanner, so its own offline lock doesn\'t block it'),
}),
response: single(z.object({
outcome: z.enum(OUTCOMES),
message: z.string(),
ticket: ticketInfo.nullable(),
checkedIn: z.number().int().describe('People checked in by this request (0 unless checked_in)'),
remaining: z.number().int().nullable().describe('Still to check in afterwards'),
session: sessionRef.nullable(),
consentStatus: z.string().nullable(),
canOverride: z.boolean().describe('consent_required: whether you may override'),
})),
async handler({ params, body, user, req }) {
await assertTicketCampus(req, params.qrCode);
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
const r = await scanTicket(params.qrCode, user!, {
eventId: body.eventId, sectionId: body.sectionId, sessionId: body.sessionId, deviceId: body.deviceId,
qty: body.people ?? null, overrideConsent: body.overrideConsent,
}, getClientIp(req));
const base = { outcome: r.outcome, message: r.message, ticket: 'ticket' in r ? toTicketInfo(r.ticket) : null, checkedIn: 0, remaining: null as number | null, session: null as { id: string; label: string } | null, consentStatus: null as string | null, canOverride: false };
if (r.outcome === 'checked_in') return { data: { ...base, checkedIn: r.qtyRedeemed, remaining: r.remaining, session: r.session } };
if (r.outcome === 'already_used') return { data: { ...base, remaining: 0 } };
if (r.outcome === 'consent_required') return { data: { ...base, consentStatus: r.consentStatus, canOverride: r.canOverride } };
return { data: base };
},
});
+130
View File
@@ -0,0 +1,130 @@
import { z } from 'zod';
import { defineRoute } from '../route';
import { notFound } from '../errors';
import { pageQuery, paged, single, skipTake, pageOf } from '../paging';
import { absoluteUrl, campusRef, id, idParams, iso, isoDate, seriesRef } from '../schemas';
import { prisma, campusesEnabled, campusWhere, resolveCampusFilter, publicEventWhere, publicOptions, getEffectiveUnitPrice } from '../legacy';
const eventSummary = z.object({
id,
title: z.string(),
startDate: isoDate,
endDate: isoDate,
location: z.string().nullable(),
price: z.number().describe('The headline price in rand (0 = free)'),
pictureUrl: z.string().nullable(),
requiresRegistration: z.boolean(),
scheduleType: z.enum(['single', 'sessions']),
campus: campusRef.nullable().describe('null = for all campuses (or campuses are off)'),
series: seriesRef.nullable(),
});
const ticketOption = z.object({
id,
name: z.string(),
price: z.number().describe('The normal price in rand'),
currentPrice: z.number().describe('What it costs now (an early-bird price while one runs)'),
isMainTicket: z.boolean(),
sessionMode: z.enum(['all', 'pick']).describe('"pick" = the buyer chooses sessions'),
});
const session = z.object({
id,
label: z.string().nullable(),
startsAt: isoDate,
endsAt: isoDate,
location: z.string().nullable(),
cancelled: z.boolean(),
});
const eventDetail = eventSummary.extend({
description: z.string().nullable(),
registrationDeadline: isoDate.nullable(),
ticketOptions: z.array(ticketOption),
sessions: z.array(session).describe('Multi-day events only; empty otherwise'),
});
const SUMMARY_SELECT = {
id: true, title: true, startDate: true, endDate: true, location: true, price: true, picture: true,
requiresRegistration: true, scheduleType: true,
campus: { select: { id: true, name: true, slug: true } },
series: { select: { id: true, name: true, slug: true } },
} as const;
type SummaryRow = {
id: string; title: string; startDate: Date; endDate: Date; location: string | null; price: number; picture: string | null;
requiresRegistration: boolean; scheduleType: 'single' | 'sessions';
campus: { id: string; name: string; slug: string } | null; series: { id: string; name: string; slug: string } | null;
};
const toSummary = (req: Parameters<typeof absoluteUrl>[0], e: SummaryRow): z.input<typeof eventSummary> => ({
id: e.id, title: e.title, startDate: iso(e.startDate), endDate: iso(e.endDate), location: e.location ?? null,
price: e.price, pictureUrl: absoluteUrl(req, e.picture), requiresRegistration: e.requiresRegistration,
scheduleType: e.scheduleType, campus: e.campus ?? null, series: e.series ?? null,
});
export const listEvents = defineRoute({
method: 'get',
path: '/events',
tag: 'Events',
summary: 'Upcoming public events',
description: 'Events anyone can see (active, not hidden, live) that have not ended, soonest first.',
access: 'public',
query: pageQuery.extend({
q: z.string().trim().max(100).optional().describe('Search the title'),
campus: z.string().max(64).optional().describe("A campus id or slug (its events plus church-wide ones), or 'all' for church-wide events only"),
series: z.string().max(120).optional().describe('A series slug'),
}),
response: paged(eventSummary),
async handler({ query, req }) {
const now = new Date();
const enabled = await campusesEnabled();
const and: object[] = [publicEventWhere(now), { endDate: { gte: now } }];
if (query.campus) and.push(campusWhere(await resolveCampusFilter(query.campus), { enabled, withAllCampuses: true }));
if (query.series) and.push({ series: { slug: query.series } });
if (query.q) and.push({ title: { contains: query.q, mode: 'insensitive' } });
const where = { AND: and };
const [rows, total] = await Promise.all([
prisma.event.findMany({ where, select: SUMMARY_SELECT, orderBy: [{ startDate: 'asc' }, { id: 'asc' }], ...skipTake(query) }),
prisma.event.count({ where }),
]);
return pageOf((rows as SummaryRow[]).map(e => toSummary(req, e)), total, query);
},
});
export const getEvent = defineRoute({
method: 'get',
path: '/events/:id',
tag: 'Events',
summary: 'One public event',
description: 'With its ticket options (internal kids check-in and volunteer tickets left out) and sessions. Hidden events can be fetched by id (they are unlisted, not private).',
access: 'public',
params: idParams,
response: single(eventDetail),
async handler({ params, req }) {
const now = new Date();
const { isHidden: _hiddenOk, ...visible } = publicEventWhere(now);
const e = await prisma.event.findFirst({
where: { id: params.id, ...visible },
select: {
...SUMMARY_SELECT, description: true, registrationDeadline: true, kidsOptionId: true, volunteerOptionId: true,
eventOptions: { include: { earlyBirdTiers: true }, orderBy: [{ isMainTicket: 'desc' }, { name: 'asc' }] },
sessions: { orderBy: { startsAt: 'asc' } },
},
});
if (!e) throw notFound('That event');
const options = publicOptions(e);
return {
data: {
...toSummary(req, e as unknown as SummaryRow),
description: e.description ?? null,
registrationDeadline: iso(e.registrationDeadline),
ticketOptions: options.map(o => ({
id: o.id, name: o.name, price: o.price, currentPrice: getEffectiveUnitPrice(o, null, now),
isMainTicket: o.isMainTicket, sessionMode: o.sessionMode,
})),
sessions: e.sessions.map(s => ({ id: s.id, label: s.label, startsAt: iso(s.startsAt), endsAt: iso(s.endsAt), location: s.location, cancelled: !!s.cancelledAt })),
},
};
},
});
+91
View File
@@ -0,0 +1,91 @@
import { z } from 'zod';
import { defineRoute } from '../route';
import { ApiError } from '../errors';
import { pageQuery, paged, single, skipTake, pageOf } from '../paging';
import { id, iso, isoDate } from '../schemas';
import { prisma } from '../legacy';
import { registrationSummary, SUMMARY_INCLUDE, toSummary } from './registrations';
import { updateProfile } from '../../services/auth';
const me = z.object({
id,
name: z.string(),
email: z.string(),
phone: z.string().nullable(),
role: z.enum(['admin', 'supervisor', 'staff', 'user']),
twoFactorEnabled: z.boolean(),
notificationPreference: z.enum(['email', 'whatsapp', 'both']),
homeCampusId: id.nullable(),
createdAt: isoDate,
});
type MeRow = { id: string; name: string; email: string; phoneNumber: string | null; role: 'admin' | 'supervisor' | 'staff' | 'user'; twoFactorEnabled: boolean | null; notificationPreference: 'email' | 'whatsapp' | 'both'; homeCampusId: string | null; createdAt: Date };
const toMe = (u: MeRow): z.input<typeof me> => ({
id: u.id, name: u.name, email: u.email, phone: u.phoneNumber ?? null, role: u.role, twoFactorEnabled: !!u.twoFactorEnabled,
notificationPreference: u.notificationPreference, homeCampusId: u.homeCampusId ?? null, createdAt: iso(u.createdAt),
});
export const getMe = defineRoute({
method: 'get',
path: '/me',
tag: 'Me',
summary: 'The signed-in person',
access: 'user',
response: single(me),
async handler({ user }) {
const u = await prisma.user.findUnique({
where: { id: user!.id },
select: { id: true, name: true, email: true, phoneNumber: true, role: true, twoFactorEnabled: true, notificationPreference: true, homeCampusId: true, createdAt: true },
});
if (!u) throw new ApiError('UNAUTHENTICATED', 'User not found');
return { data: toMe(u) };
},
});
export const updateMe = defineRoute({
method: 'patch',
path: '/me',
tag: 'Me',
summary: 'Change my details',
description: 'Only what is sent changes. A new `password` needs `currentPassword`; it sends a security notice and stops other devices skipping the 2FA code. The answer has a fresh `token` (the old one keeps working).',
access: 'user',
body: z.object({
name: z.string().trim().min(1).max(200).optional(),
email: z.email().max(200).optional(),
phone: z.string().trim().max(40).nullable().optional().describe('null or empty removes it'),
notificationPreference: z.enum(['email', 'whatsapp', 'both']).optional(),
homeCampusId: id.nullable().optional().describe('While campuses are on'),
password: z.string().min(8).max(200).optional(),
currentPassword: z.string().max(200).optional(),
}),
response: single(me).extend({ token: z.string() }),
async handler({ body, user, req }) {
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
const { user: u, token } = await updateProfile(user!.id, { ...body, phoneNumber: body.phone }, { ip: getClientIp(req), userAgent: req.headers['user-agent'] });
return { data: toMe(u), token };
},
});
export const listMyRegistrations = defineRoute({
method: 'get',
path: '/me/registrations',
tag: 'Me',
summary: 'My registrations',
description: 'Newest first. By default only ones for events that have not ended, and not cancelled; `past=true` lists everything.',
access: 'user',
query: pageQuery.extend({
past: z.enum(['true', 'false']).default('false').transform(v => v === 'true').describe('Include past and cancelled registrations'),
}),
response: paged(registrationSummary),
async handler({ query, user }) {
const now = new Date();
const where = query.past
? { userId: user!.id }
: { userId: user!.id, status: { not: 'cancelled' as const }, event: { endDate: { gte: now } } };
const [rows, total] = await Promise.all([
prisma.registration.findMany({ where, include: SUMMARY_INCLUDE, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], ...skipTake(query) }),
prisma.registration.count({ where }),
]);
return pageOf(rows.map(r => toSummary(r, now)), total, query);
},
});
+187
View File
@@ -0,0 +1,187 @@
import { z } from 'zod';
import { defineRoute, type ApiUser } from '../route';
import { ApiError, notFound } from '../errors';
import { single } from '../paging';
import { eventRef, id, idParams, iso, isoDate } from '../schemas';
import { prisma, computeRegistrationTotalDue } from '../legacy';
import { createRegistration, cancelRegistration } from '../../services/registrations';
import { startRegistrationCheckout } from '../../services/payments';
const STATUSES = ['pending', 'confirmed', 'partial_paid', 'paid', 'cancelled'] as const;
/** A registration as v2 shows it to its owner. Also used by GET /me/registrations. */
export const registrationSummary = z.object({
id,
status: z.enum(STATUSES),
createdAt: isoDate,
event: eventRef,
items: z.array(z.object({
ticket: z.string(),
variant: z.string().nullable(),
quantity: z.number().int(),
unitPrice: z.number(),
})),
totalDue: z.number().describe('In rand, after discounts'),
amountPaid: z.number().describe('In rand (refunds subtracted)'),
balance: z.number().describe('totalDue − amountPaid; 0 or less = nothing owing'),
});
const r2 = (n: number) => Math.round(n * 100) / 100;
export const SUMMARY_INCLUDE = {
event: { select: { id: true, title: true, startDate: true, endDate: true } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: { select: { name: true, price: true } }, tranches: true } },
payments: { select: { amount: true, createdAt: true } },
} as const;
type SummaryRow = {
id: string; status: (typeof STATUSES)[number]; createdAt: Date;
event: { id: string; title: string; startDate: Date; endDate: Date };
registrationOptions: { quantity: number; priceSnapshot: number | null; eventOption: { name: string; price: number } | null; variant: { name: string } | null }[];
payments: { amount: number }[];
};
export function toSummary(r: SummaryRow, now = new Date()): z.input<typeof registrationSummary> {
const totalDue = r2(computeRegistrationTotalDue(r, now));
const amountPaid = r2(r.payments.reduce((s, p) => s + p.amount, 0));
return {
id: r.id,
status: r.status,
createdAt: iso(r.createdAt),
event: { id: r.event.id, title: r.event.title, startDate: iso(r.event.startDate), endDate: iso(r.event.endDate) },
items: r.registrationOptions.map(o => ({ ticket: o.eventOption?.name ?? '', variant: o.variant?.name ?? null, quantity: o.quantity, unitPrice: o.priceSnapshot ?? o.eventOption?.price ?? 0 })),
totalDue,
amountPaid,
balance: r2(totalDue - amountPaid),
};
}
/** The owner's registration (admins may see anyone's); others get NOT_FOUND, not a hint it exists. */
async function ownRegistration(registrationId: string, user: ApiUser) {
const r = await prisma.registration.findUnique({ where: { id: registrationId }, include: SUMMARY_INCLUDE });
if (!r || (r.userId !== user.id && user.role !== 'admin')) throw notFound('That registration');
return r;
}
const attendee = z.object({
eventOptionId: id,
variantId: id.nullish(),
name: z.string().trim().min(1).max(200),
email: z.string().trim().max(200).nullish(),
phone: z.string().trim().max(40).nullish(),
dateOfBirth: z.string().max(40).nullish().describe('YYYY-MM-DD (youth events)'),
gender: z.string().max(20).nullish(),
parentName: z.string().max(200).nullish(),
parentEmail: z.string().max(200).nullish(),
parentPhone: z.string().max(40).nullish(),
});
export const register = defineRoute({
method: 'post',
path: '/registrations',
tag: 'Registrations',
summary: 'Register for an event',
description: [
'Signed in, or as a guest for a free event that allows it (`guest` then required), or with a waitlist link (`waitlistToken`).',
'Registering again for the same event adds to the existing registration. When nothing is owed it is marked paid and tickets are issued; otherwise pay with `POST /registrations/{id}/checkout`.',
'A coupon that was valid when checked but used up a moment later is reported in `couponError`; the registration is still made.',
].join('\n\n'),
access: 'optional',
status: 201,
body: z.object({
eventId: id,
options: z.array(z.object({
eventOptionId: id,
quantity: z.number().int().min(1).max(1000),
variantId: id.nullish(),
sessionIds: z.array(id).max(200).optional().describe('"Pick sessions" tickets: the sessions chosen'),
})).min(1).max(50),
guest: z.object({ name: z.string().trim().min(1).max(200), email: z.email().max(200), phone: z.string().trim().max(40).optional() }).optional(),
couponCode: z.string().trim().max(60).optional(),
attendees: z.array(attendee).max(500).optional().describe('Names for the places (events that collect them)'),
ticketDelivery: z.enum(['payer', 'each']).optional().describe('With attendees: tickets to the payer, or to each attendee'),
waitlistToken: z.string().max(200).optional(),
analyticsSessionId: z.string().max(100).optional(),
}),
response: single(registrationSummary.extend({
isNew: z.boolean().describe('false = added to an existing registration'),
couponError: z.string().nullable(),
})),
async handler({ body, user }) {
const { registration, isNew, couponError } = await createRegistration({
eventId: body.eventId,
options: body.options.map(o => ({ ...o, variantId: o.variantId ?? null })),
guestName: body.guest?.name,
guestEmail: body.guest?.email,
guestPhone: body.guest?.phone,
couponCode: body.couponCode,
attendees: body.attendees,
ticketDelivery: body.ticketDelivery,
waitlistToken: body.waitlistToken,
analyticsSessionId: body.analyticsSessionId,
}, user ? { id: user.id, role: user.role, email: user.email } : null);
const fresh = await prisma.registration.findUniqueOrThrow({ where: { id: registration.id }, include: SUMMARY_INCLUDE });
return { data: { ...toSummary(fresh), isNew, couponError } };
},
});
export const getRegistration = defineRoute({
method: 'get',
path: '/registrations/:id',
tag: 'Registrations',
summary: 'One of my registrations',
access: 'user',
params: idParams,
response: single(registrationSummary),
async handler({ params, user }) {
return { data: toSummary(await ownRegistration(params.id, user!)) };
},
});
export const cancel = defineRoute({
method: 'post',
path: '/registrations/:id/cancel',
tag: 'Registrations',
summary: 'Cancel my registration',
description: 'Not after the event has ended, and not once anything has been paid (the church can do that). Its places go to the waitlist.',
access: 'user',
params: idParams,
response: single(registrationSummary),
async handler({ params, user, req }) {
await ownRegistration(params.id, user!);
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
await cancelRegistration(params.id, user!, getClientIp(req));
return { data: toSummary(await ownRegistration(params.id, user!)) };
},
});
export const checkout = defineRoute({
method: 'post',
path: '/registrations/:id/checkout',
tag: 'Registrations',
summary: 'Pay what I owe online',
description: [
'Starts a payment with the church\'s provider (Yoco or PayFast) and returns where to send the person (`redirectUrl`).',
'If an early-bird price ran out since the registration was made, nothing is started: `priceUpdated` is true with the new amount owing — show it, then call again.',
].join('\n\n'),
access: 'user',
params: idParams,
body: z.object({
donationAmount: z.number().min(0).max(100000).optional().describe('An optional donation added on top, in rand'),
provider: z.string().max(40).optional().describe('yoco or payfast (default: the first one switched on)'),
successUrl: z.url().max(2000).optional(),
cancelUrl: z.url().max(2000).optional(),
failureUrl: z.url().max(2000).optional(),
}),
response: single(z.discriminatedUnion('priceUpdated', [
z.object({ priceUpdated: z.literal(true), newBalance: z.number(), message: z.string() }),
z.object({ priceUpdated: z.literal(false), checkoutId: z.string(), redirectUrl: z.string(), provider: z.string(), amount: z.number().describe('Charged now, in rand (with any donation)') }),
])),
async handler({ params, body, user }) {
await ownRegistration(params.id, user!);
const result = await startRegistrationCheckout(params.id, user!, body);
if (result.priceUpdated) return { data: { priceUpdated: true as const, newBalance: result.newTotal, message: result.message } };
if (!result.redirectUrl) throw new ApiError('INTERNAL', 'The payment provider did not return a payment page.');
return { data: { priceUpdated: false as const, checkoutId: result.checkoutId, redirectUrl: result.redirectUrl, provider: result.provider, amount: result.amount } };
},
});
+107
View File
@@ -0,0 +1,107 @@
import type { Request, RequestHandler, Response, Router } from 'express';
import { z } from 'zod';
import { ApiError } from './errors';
import auth from '../middleware/authMiddleware';
/**
* Every /api/v2 endpoint is declared with `defineRoute`: method, path, who may call it, and zod
* schemas for the params, query, body and response. The same declaration
* - validates the request (400 VALIDATION_FAILED with per-field messages),
* - checks the response against its schema and strips anything it doesn't list (so a field
* can't leak by being added to a Prisma include), and
* - becomes the OpenAPI document (openapi.ts) — the docs can't drift from the code.
*/
export type Access = 'public' | 'optional' | 'user' | 'staff' | 'supervisor' | 'admin';
/** The signed-in person, as the auth middleware puts it on the request. */
export type ApiUser = { id: string; name: string; email: string; role: 'admin' | 'supervisor' | 'staff' | 'user' };
export type Ctx<P, Q, B> = {
params: P;
query: Q;
body: B;
user: ApiUser | null;
req: Request;
res: Response;
};
type Schema = z.ZodType;
type Infer<S> = S extends z.ZodType ? z.infer<S> : undefined;
export type RouteDef<P extends Schema | undefined, Q extends Schema | undefined, B extends Schema | undefined, R extends Schema> = {
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
/** Express-style, relative to /api/v2: "/events/:id". */
path: string;
summary: string;
description?: string;
tag: string;
access: Access;
/**
* Staff and supervisor routes must say how they treat campus-scoped staff (see
* utils/campusScope.js and tests/campusScopeRoutes.test.js) — e.g. "scopeEvent(params.id)".
*/
campusScope?: string;
params?: P;
query?: Q;
body?: B;
response: R;
/** HTTP status on success (default 200). */
status?: number;
/** Runs after the access check, e.g. a rate limiter. */
middleware?: RequestHandler[];
handler: (ctx: Ctx<Infer<P>, Infer<Q>, Infer<B>>) => Promise<z.input<R>> | z.input<R>;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnyRoute = RouteDef<any, any, any, any>;
export const routes: AnyRoute[] = [];
const ACCESS: Record<Access, RequestHandler[]> = {
public: [],
optional: [auth.optionalAuth],
user: [auth.protect],
staff: [auth.protect, auth.staff],
supervisor: [auth.protect, auth.supervisor],
admin: [auth.protect, auth.admin],
};
export function defineRoute<P extends Schema | undefined, Q extends Schema | undefined, B extends Schema | undefined, R extends Schema>(def: RouteDef<P, Q, B, R>): RouteDef<P, Q, B, R> {
if ((def.access === 'staff' || def.access === 'supervisor') && !def.campusScope) {
throw new Error(`${def.method.toUpperCase()} /api/v2${def.path}: staff and supervisor routes must declare campusScope`);
}
routes.push(def as AnyRoute);
return def;
}
const parse = <S extends Schema | undefined>(schema: S, value: unknown): Infer<S> =>
(schema ? schema.parse(value) : undefined) as Infer<S>;
/** Adds every defined route to the router. */
export function mount(router: Router, list: AnyRoute[] = routes): void {
for (const def of list) {
const run: RequestHandler = async (req, res, next) => {
try {
const ctx = {
params: parse(def.params, req.params),
query: parse(def.query, req.query),
body: parse(def.body, req.body ?? {}),
user: (req as Request & { user?: ApiUser }).user ?? null,
req,
res,
};
const result = await def.handler(ctx);
const checked = def.response.safeParse(result);
if (!checked.success) {
console.error(`[api/v2] ${def.method.toUpperCase()} ${def.path}: response doesn't match its schema`, checked.error.issues);
throw new ApiError('INTERNAL', 'Something went wrong on the server.');
}
res.status(def.status ?? 200).json(checked.data);
} catch (err) {
next(err);
}
};
router[def.method](def.path, ...ACCESS[def.access], ...(def.middleware ?? []), run);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { z } from 'zod';
import type { Request } from 'express';
/** Shared pieces of v2 schemas. Dates go out as ISO 8601 strings (UTC). */
export const isoDate = z.iso.datetime();
export const id = z.string().min(1).max(64);
export const idParams = z.object({ id: id.describe('The id') });
/** A Date (or date string) as ISO 8601, for building responses. */
export function iso(d: Date | string): string;
export function iso(d: Date | string | null | undefined): string | null;
export function iso(d: Date | string | null | undefined): string | null {
return d == null ? null : new Date(d).toISOString();
}
/** A stored picture path ("/uploads/…") as an absolute URL on the host the request came to. */
export function absoluteUrl(req: Request, url: string | null | undefined): string | null {
const raw = String(url || '').trim();
if (!raw) return null;
if (/^https?:\/\//.test(raw)) return raw;
return `${req.protocol}://${req.get('host')}${raw.startsWith('/') ? '' : '/'}${raw}`;
}
export const campusRef = z.object({ id, name: z.string(), slug: z.string() });
export const seriesRef = z.object({ id, name: z.string(), slug: z.string() });
export const eventRef = z.object({ id, title: z.string(), startDate: isoDate, endDate: isoDate });
+369
View File
@@ -0,0 +1,369 @@
import express from 'express';
import request from 'supertest';
// /api/v2 (1.51.0): the route declarations validate requests, shape responses and errors the same
// way everywhere, and produce the OpenAPI document.
const mockDb = {
event: { findMany: jest.fn(), count: jest.fn(), findFirst: jest.fn() },
user: { findUnique: jest.fn() },
registration: { findMany: jest.fn(), count: jest.fn(), findUnique: jest.fn(), findUniqueOrThrow: jest.fn() },
};
jest.mock('../src/config/db', () => mockDb);
jest.mock('../src/utils/campuses', () => ({
campusesEnabled: jest.fn().mockResolvedValue(false),
resolveCampusFilter: jest.fn(async (v: string) => v),
campusWhere: jest.fn(() => ({})),
}));
// Signed in as whoever the test says (the real middleware checks JWTs against the database).
let signedIn: { id: string; role: string } | null = null;
jest.mock('../src/middleware/authMiddleware', () => {
const deny = (status: number, message: string) => (_req: unknown, res: { status(n: number): void }, next: (e: Error) => void) => { res.status(status); next(new Error(message)); };
return {
protect: (req: { user?: unknown }, res: { status(n: number): void }, next: (e?: Error) => void) => (signedIn ? ((req.user = signedIn), next()) : deny(401, 'Not authorized, no token')(req, res, next as (e: Error) => void)),
optionalAuth: (req: { user?: unknown }, _res: unknown, next: () => void) => { if (signedIn) req.user = signedIn; next(); },
admin: (req: { user?: { role: string } }, res: { status(n: number): void }, next: (e?: Error) => void) => (req.user?.role === 'admin' ? next() : deny(403, 'Not authorized as an admin')(req, res, next as (e: Error) => void)),
staff: (req: { user?: { role: string } }, res: { status(n: number): void }, next: (e?: Error) => void) => (['admin', 'supervisor', 'staff'].includes(req.user?.role || '') ? next() : deny(403, 'Not authorized as staff')(req, res, next as (e: Error) => void)),
supervisor: jest.fn(),
loginLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
};
});
// The services are tested through v1 (e2e); here only how v2 calls them and maps their answers.
const mockServices = { createRegistration: jest.fn(), cancelRegistration: jest.fn(), startRegistrationCheckout: jest.fn() };
jest.mock('../src/services/registrations', () => ({ createRegistration: (...a: unknown[]) => mockServices.createRegistration(...a), cancelRegistration: (...a: unknown[]) => mockServices.cancelRegistration(...a) }));
jest.mock('../src/services/payments', () => ({ startRegistrationCheckout: (...a: unknown[]) => mockServices.startRegistrationCheckout(...a) }));
const mockAuth = { signUp: jest.fn(), logIn: jest.fn(), verifyLoginCode: jest.fn(), updateProfile: jest.fn() };
jest.mock('../src/services/auth', () => ({
signUp: (...a: unknown[]) => mockAuth.signUp(...a), logIn: (...a: unknown[]) => mockAuth.logIn(...a),
verifyLoginCode: (...a: unknown[]) => mockAuth.verifyLoginCode(...a), updateProfile: (...a: unknown[]) => mockAuth.updateProfile(...a),
}));
const mockCheckin = { previewScan: jest.fn(), scanTicket: jest.fn() };
jest.mock('../src/services/checkin', () => ({ previewScan: (...a: unknown[]) => mockCheckin.previewScan(...a), scanTicket: (...a: unknown[]) => mockCheckin.scanTicket(...a) }));
const mockScope = { scope: null as object | null, campusId: 'north' as string | null };
jest.mock('../src/utils/campusScope', () => ({
scopeFor: async () => mockScope.scope,
eventOf: async () => ({ campusId: mockScope.campusId, campusName: 'North' }),
canView: (scope: { campusIds: string[] }, campusId: string | null) => !campusId || scope.campusIds.includes(campusId),
}));
import { v2Router } from '../src/v2';
import { ServiceError } from '../src/services/errors';
import { routes } from '../src/v2/route';
import { buildOpenApi } from '../src/v2/openapi';
const app = express();
app.use(express.json());
app.use('/api/v2', v2Router());
const event = (over: Record<string, unknown> = {}) => ({
id: 'e1', title: 'Youth Camp', startDate: new Date('2026-12-04T16:00:00Z'), endDate: new Date('2026-12-06T12:00:00Z'),
location: 'Camp Joy', price: 600, picture: '/uploads/events/camp.jpg', requiresRegistration: true, scheduleType: 'single',
campus: null, series: { id: 's1', name: 'Camps', slug: 'camps' }, ...over,
});
beforeEach(() => { signedIn = null; jest.clearAllMocks(); });
describe('GET /api/v2/events', () => {
test('a page of public events, with meta and ISO dates', async () => {
mockDb.event.findMany.mockResolvedValue([event()]);
mockDb.event.count.mockResolvedValue(31);
const res = await request(app).get('/api/v2/events?page=2&limit=10&q=camp');
expect(res.status).toBe(200);
expect(res.body.meta).toEqual({ page: 2, limit: 10, total: 31, pages: 4 });
expect(res.body.data[0]).toMatchObject({ id: 'e1', startDate: '2026-12-04T16:00:00.000Z', series: { slug: 'camps' } });
expect(res.body.data[0].pictureUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/uploads\/events\/camp\.jpg$/);
const args = mockDb.event.findMany.mock.calls[0][0];
expect(args).toMatchObject({ skip: 10, take: 10 });
expect(args.where.AND).toEqual(expect.arrayContaining([{ isActive: true, isHidden: false, goLiveAt: { lte: expect.any(Date) } }, { title: { contains: 'camp', mode: 'insensitive' } }]));
});
test('fields the schema does not list are never sent', async () => {
mockDb.event.findMany.mockResolvedValue([event({ cashupStatus: 'closed', createdById: 'u9' })]);
mockDb.event.count.mockResolvedValue(1);
const res = await request(app).get('/api/v2/events');
expect(Object.keys(res.body.data[0]).sort()).toEqual(['campus', 'endDate', 'id', 'location', 'pictureUrl', 'price', 'requiresRegistration', 'scheduleType', 'series', 'startDate', 'title']);
});
test('a bad query is a VALIDATION_FAILED error naming the field', async () => {
const res = await request(app).get('/api/v2/events?limit=500&page=0');
expect(res.status).toBe(400);
expect(res.body.error.code).toBe('VALIDATION_FAILED');
expect(Object.keys(res.body.error.fields).sort()).toEqual(['limit', 'page']);
expect(mockDb.event.findMany).not.toHaveBeenCalled();
});
});
describe('GET /api/v2/events/:id', () => {
test('leaves out kids and volunteer tickets and shows the early-bird price now', async () => {
mockDb.event.findFirst.mockResolvedValue({
...event(), description: 'A weekend away', registrationDeadline: null, kidsOptionId: 'kids', volunteerOptionId: null,
eventOptions: [
{ id: 'o1', name: 'Camper', price: 600, isMainTicket: true, sessionMode: 'all', earlyBirdTiers: [{ id: 't', price: 500, deadline: new Date(Date.now() + 864e5), stockLimit: 0 }] },
{ id: 'kids', name: 'Kids check-in', price: 0, isMainTicket: false, sessionMode: 'all', earlyBirdTiers: [] },
],
sessions: [],
});
const res = await request(app).get('/api/v2/events/e1');
expect(res.status).toBe(200);
expect(res.body.data.ticketOptions).toEqual([{ id: 'o1', name: 'Camper', price: 600, currentPrice: 500, isMainTicket: true, sessionMode: 'all' }]);
});
test('an unknown or private event is NOT_FOUND', async () => {
mockDb.event.findFirst.mockResolvedValue(null);
const res = await request(app).get('/api/v2/events/nope');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: { code: 'NOT_FOUND', message: "That event wasn't found." } });
});
});
describe('me', () => {
test('signed out is UNAUTHENTICATED in the v2 shape', async () => {
const res = await request(app).get('/api/v2/me');
expect(res.status).toBe(401);
expect(res.body.error.code).toBe('UNAUTHENTICATED');
});
test('my registrations with totals and what is owing', async () => {
signedIn = { id: 'u1', role: 'user' };
mockDb.registration.findMany.mockResolvedValue([{
id: 'r1', status: 'partial_paid', createdAt: new Date('2026-10-01T10:00:00Z'), discountTotal: 0,
event: { id: 'e1', title: 'Youth Camp', startDate: new Date('2026-12-04T16:00:00Z'), endDate: new Date('2026-12-06T12:00:00Z') },
registrationOptions: [{ quantity: 2, priceSnapshot: 600, eventOption: { name: 'Camper', price: 600, earlyBirdTiers: [] }, variant: null, tranches: [] }],
payments: [{ amount: 500, createdAt: new Date() }],
}]);
mockDb.registration.count.mockResolvedValue(1);
const res = await request(app).get('/api/v2/me/registrations');
expect(res.status).toBe(200);
expect(res.body.data[0]).toMatchObject({ id: 'r1', totalDue: 1200, amountPaid: 500, balance: 700, items: [{ ticket: 'Camper', variant: null, quantity: 2, unitPrice: 600 }] });
expect(mockDb.registration.findMany.mock.calls[0][0].where).toMatchObject({ userId: 'u1', status: { not: 'cancelled' } });
});
});
describe('the API description', () => {
test('openapi.json is for admins', async () => {
signedIn = { id: 'u1', role: 'user' };
expect((await request(app).get('/api/v2/openapi.json')).body.error.code).toBe('FORBIDDEN');
signedIn = { id: 'a1', role: 'admin' };
const res = await request(app).get('/api/v2/openapi.json');
expect(res.status).toBe(200);
expect(res.body.openapi).toBe('3.1.0');
});
test('every route is described, with its parameters, auth and response', () => {
const doc = buildOpenApi({ version: 'test' });
for (const r of routes) {
const op = (doc.paths as Record<string, Record<string, Record<string, unknown>>>)[r.path.replace(/:(\w+)/g, '{$1}')][r.method];
expect(op).toBeDefined();
expect(op.responses).toHaveProperty(String(r.status ?? 200));
}
const list = doc.paths['/events'].get as { parameters: { name: string; in: string }[]; security?: unknown };
expect(list.parameters.map(p => p.name)).toEqual(['page', 'limit', 'q', 'campus', 'series']);
expect(list.security).toBeUndefined();
expect((doc.paths['/me'].get as { security: unknown }).security).toEqual([{ bearerAuth: [] }]);
expect((doc.paths['/events/{id}'].get as { parameters: { name: string; in: string; required: boolean }[] }).parameters[0]).toMatchObject({ name: 'id', in: 'path', required: true });
});
test('an unknown v2 path is a v2 NOT_FOUND', async () => {
const res = await request(app).get('/api/v2/nothing-here');
expect(res.status).toBe(404);
expect(res.body.error.code).toBe('NOT_FOUND');
});
});
describe('registrations', () => {
const row = (over: Record<string, unknown> = {}) => ({
id: 'r1', userId: 'u1', status: 'pending', createdAt: new Date('2026-10-01T10:00:00Z'), discountTotal: 0,
event: { id: 'e1', title: 'Youth Camp', startDate: new Date('2026-12-04T16:00:00Z'), endDate: new Date('2026-12-06T12:00:00Z') },
registrationOptions: [{ quantity: 1, priceSnapshot: 600, eventOption: { name: 'Camper', price: 600, earlyBirdTiers: [] }, variant: null, tranches: [] }],
payments: [], ...over,
});
test('registering passes a guest through and answers 201 with what is owing', async () => {
mockServices.createRegistration.mockResolvedValue({ registration: { id: 'r1' }, isNew: true, couponError: null });
mockDb.registration.findUniqueOrThrow.mockResolvedValue(row());
const res = await request(app).post('/api/v2/registrations').send({
eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 1 }], guest: { name: 'Thandi', email: 'thandi@example.com' },
});
expect(res.status).toBe(201);
expect(res.body.data).toMatchObject({ id: 'r1', isNew: true, couponError: null, totalDue: 600, balance: 600 });
expect(mockServices.createRegistration).toHaveBeenCalledWith(expect.objectContaining({ eventId: 'e1', guestName: 'Thandi', guestEmail: 'thandi@example.com' }), null);
});
test('a bad body never reaches the service', async () => {
const res = await request(app).post('/api/v2/registrations').send({ eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 0 }], guest: { name: 'T', email: 'not-an-email' } });
expect(res.status).toBe(400);
expect(Object.keys(res.body.error.fields).sort()).toEqual(['guest.email', 'options.0.quantity']);
expect(mockServices.createRegistration).not.toHaveBeenCalled();
});
test("the service's errors keep their meaning", async () => {
mockServices.createRegistration.mockRejectedValueOnce(new ServiceError(401, 'Authentication required for this event'));
const res = await request(app).post('/api/v2/registrations').send({ eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 1 }] });
expect(res.status).toBe(401);
expect(res.body.error).toEqual({ code: 'UNAUTHENTICATED', message: 'Authentication required for this event' });
mockServices.createRegistration.mockRejectedValueOnce(new ServiceError(400, '"Camper" is sold out or does not have enough stock (0 remaining).'));
const sold = await request(app).post('/api/v2/registrations').send({ eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 1 }] });
expect(sold.body.error.code).toBe('VALIDATION_FAILED');
});
test("someone else's registration is NOT_FOUND, and can't be cancelled or paid", async () => {
signedIn = { id: 'u2', role: 'user' };
mockDb.registration.findUnique.mockResolvedValue(row());
for (const [method, path] of [['get', '/api/v2/registrations/r1'], ['post', '/api/v2/registrations/r1/cancel'], ['post', '/api/v2/registrations/r1/checkout']] as const) {
const res = await request(app)[method](path).send({});
expect([path, res.status]).toEqual([path, 404]);
}
expect(mockServices.cancelRegistration).not.toHaveBeenCalled();
expect(mockServices.startRegistrationCheckout).not.toHaveBeenCalled();
});
test('cancelling my registration', async () => {
signedIn = { id: 'u1', role: 'user' };
mockDb.registration.findUnique.mockResolvedValueOnce(row()).mockResolvedValueOnce(row({ status: 'cancelled' }));
mockServices.cancelRegistration.mockResolvedValue({});
const res = await request(app).post('/api/v2/registrations/r1/cancel');
expect(res.status).toBe(200);
expect(res.body.data.status).toBe('cancelled');
});
test('checkout: the payment page, or the new balance when an early-bird price ran out', async () => {
signedIn = { id: 'u1', role: 'user' };
mockDb.registration.findUnique.mockResolvedValue(row());
mockServices.startRegistrationCheckout.mockResolvedValueOnce({ checkoutId: 'c1', redirectUrl: 'https://pay.example/c1', provider: 'yoco', amount: 650, registration: {} });
const pay = await request(app).post('/api/v2/registrations/r1/checkout').send({ donationAmount: 50 });
expect(pay.body.data).toEqual({ priceUpdated: false, checkoutId: 'c1', redirectUrl: 'https://pay.example/c1', provider: 'yoco', amount: 650 });
expect(mockServices.startRegistrationCheckout).toHaveBeenCalledWith('r1', signedIn, { donationAmount: 50 });
mockServices.startRegistrationCheckout.mockResolvedValueOnce({ priceUpdated: true, newTotal: 700, message: 'Prices changed' });
const changed = await request(app).post('/api/v2/registrations/r1/checkout').send({});
expect(changed.body.data).toEqual({ priceUpdated: true, newBalance: 700, message: 'Prices changed' });
});
});
describe('the frontend types', () => {
test('frontend/src/lib/api-v2/openapi.json is up to date (run `npm run api:types` in the repo root)', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { OPENAPI_FILE, currentDocument } = require('../scripts/openapi');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const saved = require('fs').readFileSync(OPENAPI_FILE, 'utf8').replace(/\r\n/g, '\n');
expect(saved).toBe(currentDocument());
});
});
describe('accounts', () => {
const sess = { id: 'u1', name: 'Thandi', email: 't@x.co', role: 'user', token: 'jwt', newsPromptPending: true, noticePromptPending: false };
test('log in: a token, or a code to finish with', async () => {
mockAuth.logIn.mockResolvedValueOnce({ status: 'signed_in', session: sess });
const ok = await request(app).post('/api/v2/auth/login').send({ identifier: 't@x.co', password: 'secret' });
expect(ok.body.data).toEqual({ status: 'signed_in', token: 'jwt', user: { id: 'u1', name: 'Thandi', email: 't@x.co', role: 'user' }, newsPromptPending: true, noticePromptPending: false });
mockAuth.logIn.mockResolvedValueOnce({ status: 'code_required', challenge: { challengeId: 'c1', method: 'email', destination: 't***@x.co' }, enrolling: true });
const code = await request(app).post('/api/v2/auth/login').send({ identifier: 't@x.co', password: 'secret' });
expect(code.body.data).toEqual({ status: 'code_required', challengeId: 'c1', method: 'email', destination: 't***@x.co', enrolling: true });
// The token never comes with a code challenge.
expect(code.body.data.token).toBeUndefined();
});
test('a wrong password is UNAUTHENTICATED and a locked account RATE_LIMITED', async () => {
mockAuth.logIn.mockRejectedValueOnce(new ServiceError(401, 'Invalid email or password', undefined, { v1Status: 400 }));
expect((await request(app).post('/api/v2/auth/login').send({ identifier: 'a', password: 'b' })).body.error.code).toBe('UNAUTHENTICATED');
mockAuth.logIn.mockRejectedValueOnce(new ServiceError(429, 'Too many attempts. Try again in 5 minute(s).'));
const locked = await request(app).post('/api/v2/auth/login').send({ identifier: 'a', password: 'b' });
expect([locked.status, locked.body.error.code]).toEqual([429, 'RATE_LIMITED']);
});
test('sign up needs the terms accepted and a password of 8+', async () => {
const bad = await request(app).post('/api/v2/auth/signup').send({ name: 'T', email: 't@x.co', password: 'short' });
expect(Object.keys(bad.body.error.fields).sort()).toEqual(['acceptTerms', 'password']);
mockAuth.signUp.mockResolvedValue({ id: 'u9', name: 'T', email: 't@x.co', role: 'user', token: 'jwt9' });
const ok = await request(app).post('/api/v2/auth/signup').send({ name: 'T', email: 't@x.co', password: 'long-enough', acceptTerms: true, newsOptIn: true });
expect(ok.status).toBe(201);
expect(ok.body.data).toMatchObject({ status: 'signed_in', token: 'jwt9' });
expect(mockAuth.signUp).toHaveBeenCalledWith(expect.objectContaining({ newsOptIn: true }), expect.any(Object));
});
test('PATCH /me changes only what is sent and hands back a fresh token', async () => {
signedIn = { id: 'u1', role: 'user' };
mockAuth.updateProfile.mockResolvedValue({ user: { id: 'u1', name: 'New', email: 't@x.co', phoneNumber: null, role: 'user', twoFactorEnabled: false, notificationPreference: 'email', homeCampusId: null, createdAt: new Date('2026-01-01T00:00:00Z'), tokenVersion: 0 }, token: 'jwt2' });
const res = await request(app).patch('/api/v2/me').send({ name: 'New' });
expect(res.body).toMatchObject({ data: { name: 'New', phone: null }, token: 'jwt2' });
expect(res.body.data.tokenVersion).toBeUndefined();
expect(mockAuth.updateProfile).toHaveBeenCalledWith('u1', { name: 'New', phoneNumber: undefined }, expect.any(Object));
});
});
describe('check-in', () => {
const ticket = { id: 't1', quantity: 2, isUsed: false, eventId: 'e1', event: { title: 'Youth Camp' }, registrationOption: { eventOption: { id: 'o1', name: 'Camper', isMainTicket: true } }, usages: [] };
afterEach(() => { mockScope.scope = null; });
test('staff only', async () => {
signedIn = { id: 'u1', role: 'user' };
expect((await request(app).post('/api/v2/checkin/QR1').send({})).body.error.code).toBe('FORBIDDEN');
});
test('every normal result is a 200 with an outcome', async () => {
signedIn = { id: 's1', role: 'staff' };
mockCheckin.scanTicket.mockResolvedValueOnce({ outcome: 'checked_in', message: 'Ticket scanned successfully (1 of 2 redeemed, 1 remaining)', session: null, ticketUsage: {}, ticket, qtyRedeemed: 1, totalRedeemed: 1, remaining: 1 });
const ok = await request(app).post('/api/v2/checkin/QR1').send({ people: 1, eventId: 'e1' });
expect(ok.body.data).toEqual({ outcome: 'checked_in', message: expect.any(String), ticket: { id: 't1', eventId: 'e1', eventTitle: 'Youth Camp', ticketName: 'Camper', quantity: 2 }, checkedIn: 1, remaining: 1, session: null, consentStatus: null, canOverride: false });
expect(mockCheckin.scanTicket).toHaveBeenCalledWith('QR1', signedIn, expect.objectContaining({ qty: 1, eventId: 'e1' }), expect.anything());
mockCheckin.scanTicket.mockResolvedValueOnce({ outcome: 'consent_required', message: 'Parental consent is missing', consentStatus: 'missing', canOverride: false, ticket });
const consent = await request(app).post('/api/v2/checkin/QR1').send({});
expect(consent.status).toBe(200);
expect(consent.body.data).toMatchObject({ outcome: 'consent_required', checkedIn: 0, consentStatus: 'missing' });
mockCheckin.scanTicket.mockResolvedValueOnce({ outcome: 'transferred', message: 'Given to Sipho' });
expect((await request(app).post('/api/v2/checkin/QR1').send({})).body.data).toMatchObject({ outcome: 'transferred', ticket: null });
});
test("campus-scoped staff can't check in another campus's ticket", async () => {
signedIn = { id: 's1', role: 'staff' };
mockScope.scope = { campusIds: ['south'] };
const res = await request(app).post('/api/v2/checkin/QR1').send({});
expect(res.body.error).toEqual({ code: 'FORBIDDEN', message: 'This ticket is for an event at North.' });
expect(mockCheckin.scanTicket).not.toHaveBeenCalled();
});
test('the preview names the holder and what is left', async () => {
signedIn = { id: 's1', role: 'staff' };
mockCheckin.previewScan.mockResolvedValue({ outcome: 'found', ticket: { ...ticket, consentStatus: 'not_required', remaining: 2, session: null, sessionError: null, registrationOption: { id: 'ro1', eventOption: { id: 'o1', name: 'Camper' }, variant: null }, event: { id: 'e1', title: 'Youth Camp' }, user: { id: 'u1', name: 'Thandi' }, attendee: { name: 'Lulu' } } });
const res = await request(app).get('/api/v2/checkin/QR1');
expect(res.body.data.ticket).toMatchObject({ attendee: 'Lulu', bookedBy: 'Thandi', ticketOptionId: 'o1', remaining: 2, consentStatus: 'not_required' });
});
});
describe('v1 deprecation headers', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { successorOf } = require('../src/v2/deprecation');
test('v1 endpoints with a v2 replacement point at it; the rest say nothing', () => {
expect(successorOf('GET', '/api/events')).toBe('/api/v2/events');
expect(successorOf('GET', '/api/events/e1')).toBe('/api/v2/events/e1');
expect(successorOf('GET', '/api/events/all')).toBeNull();
expect(successorOf('DELETE', '/api/registrations/r1')).toBe('/api/v2/registrations/r1/cancel');
expect(successorOf('POST', '/api/tickets/scan/QR1')).toBe('/api/v2/checkin/QR1');
expect(successorOf('GET', '/api/reports/summary')).toBeNull();
expect(successorOf('GET', '/api/v2/events')).toBeNull();
});
});
describe('shared middleware on v2', () => {
test("v1-style refusals become v2 errors, keeping the specific code as reason", async () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { legacyErrorsToV2 } = require('../src/v2/errors');
const mini = express();
mini.use('/api/v2', legacyErrorsToV2);
mini.get('/api/v2/limited', (_req, res) => { res.status(429).json({ message: 'Too many requests, please try again later.' }); });
mini.post('/api/v2/locked', (_req, res) => { res.status(403).json({ code: 'LICENCE_READ_ONLY', message: 'Read only.' }); });
mini.get('/api/v2/twofa', (_req, res) => { res.status(401).json({ code: 'TWO_FACTOR_REQUIRED', message: 'Set up 2FA.' }); });
mini.get('/api/v2/fine', (_req, res) => { res.json({ data: { message: 'not an error' } }); });
expect((await request(mini).get('/api/v2/limited')).body).toEqual({ error: { code: 'RATE_LIMITED', message: 'Too many requests, please try again later.' } });
expect((await request(mini).post('/api/v2/locked')).body).toEqual({ error: { code: 'READ_ONLY', message: 'Read only.', reason: 'LICENCE_READ_ONLY' } });
expect((await request(mini).get('/api/v2/twofa')).body.error).toMatchObject({ code: 'UNAUTHENTICATED', reason: 'TWO_FACTOR_REQUIRED' });
expect((await request(mini).get('/api/v2/fine')).body).toEqual({ data: { message: 'not an error' } });
});
});
+25
View File
@@ -3,8 +3,12 @@ const mockDb = {
webhookDelivery: { updateMany: jest.fn(), findUnique: jest.fn(), update: jest.fn(), create: jest.fn(), findMany: jest.fn(), deleteMany: jest.fn() },
user: { findUnique: jest.fn() },
registration: { findUnique: jest.fn() },
payment: { findUnique: jest.fn() },
ticketUsage: { findUnique: jest.fn() },
event: { findUnique: jest.fn() },
};
jest.mock('../src/config/db', () => mockDb);
jest.mock('../src/utils/pricing', () => ({ computeRegistrationTotalDue: () => 1200 }));
const crypto = require('crypto');
const webhooks = require('../src/utils/webhooks');
@@ -112,4 +116,25 @@ describe('outbound webhooks', () => {
expect(made[0].eventId).toBe(made[1].eventId);
expect(made[0].payload.data.person).toMatchObject({ id: 'u1', email: 't@test.local' });
});
// The examples shown in Site settings → Webhooks must have the same shape as what is sent.
test('each event type has an example shaped like the real payload', async () => {
const shape = (v) => (v && typeof v === 'object' && !(v instanceof Date) && !Array.isArray(v)
? Object.fromEntries(Object.keys(v).sort().map(k => [k, shape(v[k])]))
: Array.isArray(v) ? v.map(shape) : '·');
const ev = { id: 'e1', title: 'Camp', startDate: new Date(), endDate: new Date(), kidsOptionId: null, volunteerOptionId: null };
const user = { id: 'u1', name: 'T', email: 't@x', phoneNumber: '+27' };
const reg = (status) => ({ id: 'r1', status, createdAt: new Date(), userId: 'u1', seasonPassPurchaseId: null, event: ev, user, payments: [{ amount: 1200 }],
registrationOptions: [{ eventOptionId: 'o1', quantity: 2, priceSnapshot: 600, eventOption: { id: 'o1', name: 'Camper' }, variant: { name: 'Bunk' }, tranches: [] }] });
mockDb.payment.findUnique.mockResolvedValue({ id: 'p1', amount: 1200, method: 'card', provider: 'yoco', isDonation: false, registrationId: 'r1', createdAt: new Date(), userId: 'u1', user, event: ev });
mockDb.ticketUsage.findUnique.mockResolvedValue({ id: 't1', scannedAt: new Date(), quantityRedeemed: 1, manual: false, offline: false, session: { id: 's1', label: 'Fri', startsAt: new Date() },
ticket: { id: 'k1', userId: 'u1', registrationOptionId: 'o1', event: ev, user, holder: null, attendee: { name: 'T' }, registrationOption: { eventOptionId: 'o1' } } });
mockDb.user.findUnique.mockResolvedValue({ ...user, role: 'user', createdAt: new Date() });
mockDb.event.findUnique.mockResolvedValue({ ...ev, description: 'd', location: 'l', price: 600, isActive: true, isHidden: false, goLiveAt: new Date(), campus: { name: 'N', slug: 'n' }, series: null });
for (const t of webhooks.EVENT_TYPES) {
const status = { 'registration.paid': 'paid', 'registration.cancelled': 'cancelled' }[t.type] || 'pending';
mockDb.registration.findUnique.mockResolvedValue(reg(status));
const built = await webhooks.buildPayload(t.type, 'x');
expect([t.type, shape(t.example)]).toEqual([t.type, shape(built.data)]);
}
});
});
+25
View File
@@ -0,0 +1,25 @@
{
// The backend is moving to TypeScript a file at a time (1.51.0): .js files are compiled as they
// are (not type-checked), .ts files are checked strictly. `npm run build` writes dist/, which is
// what production runs (dist/index.js); dev and the e2e tests run src/ through tsx.
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"lib": ["ES2023"],
"rootDir": "src",
"outDir": "dist",
"allowJs": true,
"checkJs": false,
"strict": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"types": ["node", "jest"]
},
"include": ["src"]
}
+93 -8
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-28, after **1.50.1** (and Hub 1.5.0). The plan itself is in
Last updated: 2026-10-29, after **1.51.6** (and Hub 1.5.0). The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -14,6 +14,12 @@ encrypted settings unreadable or break existing installs.
| Version | What |
|---------|------|
| 1.51.3 | **Express 5** (qs query parser and `req.body = {}` kept); scanner and at-the-door on v2 check-in; shared middleware answers v2-shaped on `/api/v2` (`legacyErrorsToV2`, `reason`); v1 `Deprecation` + `successor-version` headers (`src/v2/deprecation.ts`) |
| 1.51.2 | **v2 accounts and check-in**: signup, login (+ code step), `PATCH /me`; check-in look-up and scan with `outcome`s. `src/services/auth.ts` (from userController) and `src/services/checkin.ts` (from ticketController); login/signup/profile pages on v2 |
| 1.51.1 | **v2 registrations and payments**: register, get, cancel, checkout; create/cancel moved to `src/services/registrations.ts`, online checkout to `src/services/payments.ts` (v1 uses them too); typed frontend calls (`apiV2`, `npm run api:types`); register page, Pay now and My Events cancel on v2 |
| 1.51.6 | Command palette lists every report (from `ReportCatalog`) and every dashboard page (e2e drift check); exceljs → ExcelTS 10 (`@cj-tech-master/excelts`, no dependencies; e2e `e2e/excel.spec.ts`); backend `engines.node` >= 22 |
| 1.51.0 | **TypeScript backend + `/api/v2` foundations** (§14): tsconfig (`allowJs`), build to `dist/`, launcher `src/index.js` → `src/app.js`; v2 route declarations with zod → validation, response stripping and OpenAPI 3.1 at `/docs/v2`; first endpoints events, event, me, my registrations. Tests `tests/v2.test.ts`, e2e `e2e/api-v2.spec.ts` |
| 1.50.2 | Searchable **event picker** (`components/shared/EventPicker.tsx`) on staff/supervisor pages and the kiosk; searchable reports events list; **How webhooks work** guide on Site settings → Webhooks (examples from `EVENT_TYPES[].example`). e2e `e2e/event-picker.spec.ts` |
| 1.50.1 | Long lists: audit-log search/role filter/all actions and inclusive To date, data-request search and paging, webhook-delivery status filter and Load older, supervisor Payments loads only what it needs (`unpaid`, `userId`, `from`, `donations` filters). `components/shared/Pager.tsx`; e2e `e2e/long-lists.spec.ts` |
| 1.50.0 | **Accessibility pass** (§11, WCAG 2.2 AA) on public pages, auth, registration, payment, My Events and the kiosk: skip link, focus ring, labelled fields, named quantity buttons, announced errors, `ModalOverlay` popups (focus trap, Escape), contrast and underlined in-text links, reduce-motion; Branding warns about a too-light primary colour. axe + keyboard e2e in `e2e/accessibility.spec.ts` |
| 1.49.2 | **Saved filters** (§9 part 3) on registrations, users, events and reports (`/api/saved-filters`, `components/shared/SavedFilters.tsx`). Migration `20261028180000_saved_filters` |
@@ -60,8 +66,18 @@ encrypted settings unreadable or break existing installs.
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
a test server (both done 2026-10-28).
1. **1.51.0 — TypeScript backend, OpenAPI and `/api/v2`** (§14), then **2.0.0 — AI helpers** (§15).
1. **1.51.7 – 1.51.13: every endpoint onto v2**, one area per release (roadmap build order, row
11b): services in TS, screens on `apiV2`, v1 routes into `SUCCESSORS`. 1.51.13 gives incoming
webhooks and links in sent messages v2 URLs, with the v1 paths still answering (links redirect,
webhooks handled by the same code). Then **2.0.0** — AI helpers and v1 removed.
2. **Servers run Node 24** (Joshua, 2026-10-29), so the backend may need Node 22+. Still to do:
pg-boss 11 (it would drop pg-boss 10's cron-parser 4), and move from ExcelTS — deprecated in
favour of `documonster` by the same author (pre-1.0 in Oct 2026) — once documonster is stable.
Languages (Afrikaans, §10) are removed from scope (2026-10-28).
2. AI rules decided 2026-10-29 (see §15 in the roadmap): the AI buttons show only when the
licence's monthly cap is above R0, and are disabled (not hidden) once it's reached. The Hub
charges the real cost plus a markup % (a default and a per-licence override) and counts the cap
in charged rand. AI endpoints are `/api/v2` only.
## Working on this repo
@@ -72,20 +88,20 @@ a test server (both done 2026-10-28).
or serious fix) → `git merge --no-ff`
into `main` → `git tag vX.Y.Z` → `git push origin main <branch> vX.Y.Z`. One minor
version per feature.
- **Before tagging:** run `npx eslint src` in `frontend` over the **whole** app (the deploy runs `next build`, which fails on any lint error, e.g. an unescaped ' or " in JSX text). If a dev server is running, a full check build needs its own folder: `NEXT_DIST_DIR=.next-buildcheck npx next build` (then delete it and `git checkout frontend/tsconfig.json`).
- **Before tagging:** run `npx eslint src` in `frontend` over the **whole** app (since Next 16 `next build` no longer lints — it still fails on TypeScript errors — so lint errors only show up if you run it; keep it at 0 errors). If a dev server is running, a full check build needs its own folder: `NEXT_DIST_DIR=.next-buildcheck npx next build` (then delete it and `git checkout frontend/tsconfig.json`).
- **Changelog:** add entries to `[Unreleased]` as you go, in plain language. CHANGELOG.md is the
full log; WHATS-NEW.md is what churches see before updating (the Hub reads it from main every
15 minutes, for tagged versions only and sets the latest version from the tags — nothing to do in the Hub).
- **Docs to keep in step:** CHANGELOG.md, FEATURES.md, README.md, SETTINGS.md,
backend/API_DOCUMENTATION.md, the `/docs` route list in `backend/src/index.js`, and
backend/API_DOCUMENTATION.md, the `/docs` route list in `backend/src/app.js` (v2 documents itself), and
in-app help in `frontend/src/content/help/` (register new pages in `registry.ts`).
- **Prisma migrations:** `npx prisma migrate dev --create-only --name x` against a local
DB. Prisma 7 keeps adding an unrelated `_EventNotifyRecipients` primary-key/index
change — delete those two blocks from the generated SQL. It also rewrites the comment
in `migration_lock.toml`; `git checkout` that file back. New `AdminAuditAction` values
need a migration.
- **Server Node version:** the deployment notes install Node 20, so dependencies must
support Node 20 (that's why pg-boss is pinned to 10.x).
- **Server Node version:** Node 24 LTS on every server (2026-10-29); new installs get it too.
The backend declares `engines.node >= 22`. pg-boss is still on 10.x (11 is the next step).
- **Deploying:** `bash scripts/deploy.sh [tag]` on the server (no tag = latest main). Each install
keeps its own `ecosystem.config.js` (PM2 app names, port), which is not in git. The script
runs from a temp copy of itself and rolls back on failure before the PM2 reload. It was
@@ -443,8 +459,77 @@ a test server (both done 2026-10-28).
(`app/dashboard/admin/audit-log/page.tsx`) — the filter is built from that list.
- **Still loading everything (fine for now, watch as installs grow):** user pickers that call
`fetchAllUsers` (Payments, Email/WhatsApp attendees, Forms, Manual) — a server-side search
picker would replace them; event pickers using `/api/events/all?includePast=true`; the coupons
list; `GET /api/registrations` without `page` (other callers still use the plain array).
picker would replace them; the coupons list; `GET /api/registrations` without `page` (other callers still use the plain array).
## Command palette and Excel (1.51.6)
- **Palette:** reports come from `components/reports/ReportCatalog.ts` — add a report there and it
appears. A new dashboard page needs a line in `lib/commandPages.ts`, or an entry in
`NOT_IN_PALETTE` in `e2e/dashboard.spec.ts` (which fails on any page that's neither).
- **Excel:** `require('@cj-tech-master/excelts')` — the exceljs API (`new ExcelJS.Workbook()`,
`xlsx.write(res)`). Reports need `kind: 'table'` (or `'layered'`) for the main table.
## Next.js 16 (1.51.5)
- Turbopack for dev and build (the default). Lint with `npm run lint` / `npx eslint src` (`next lint`
is gone). There's no middleware/proxy file — add `src/proxy.ts` (Next 16's name) only if needed.
- **React Compiler lint rules are warnings, not errors** (`eslint.config.mjs`): 181 existing places,
mostly `useEffect(() => { load(); }, [...])`. Fix them in a screen when it's moved onto v2
(1.51.6 – 1.51.11), then turn the rules back to errors. Don't add new ones.
- `eslint-config-next` 16's bundled plugins (react, import, jsx-a11y) don't list ESLint 10 as a peer
yet; they work, and npm notes the override. Revisit when they publish ESLint 10 support.
## TypeScript and API v2 (1.51.0)
- **Starting the backend:** PM2 runs `src/index.js`, a launcher. With plain Node it runs
`dist/app.js` when `dist/build.json` has this version; otherwise tsx compiles on the fly (with a
warning). tsx and typescript are runtime dependencies for that fallback. `npm run dev` and e2e
use tsx, so they never pick up a stale `dist/`. The server is `src/app.js`.
- **Paths:** `dist/` mirrors `src/`, so `path.join(__dirname, '..', '..', 'public')` works from
both. Non-code files in `src/` aren't copied (the favicon is read from `src/`).
- **New code in TypeScript.** Convert a JS module when a release touches it; until then import it
through `src/v2/legacy.ts` with a typed signature. TypeScript infers loose types from JS (the
Prisma client especially), so v2 code takes `prisma` from there, typed as `PrismaClient`.
- **A v2 endpoint** is `defineRoute({ method, path, tag, summary, access, params?, query?, body?,
response, handler })` in `src/v2/resources/<area>.ts`, imported in `src/v2/index.ts`. Lists use
`pageQuery`, `paged()` and `pageOf()`; single items `single()`. Dates go out as ISO strings
(`iso()`), so response schemas use `isoDate`. Throw `ApiError(code, message)` or `notFound()`.
The response is parsed by its schema: unknown fields are dropped, and a mismatch is a 500 plus a
log line (tests catch it). Staff/supervisor routes must declare `campusScope` (start-up fails
otherwise).
- **Services** (`src/services/*.ts`, 1.51.1): business logic both API versions call. Throw
`ServiceError(status, message)` (`fail()`); v1 controllers keep their old answers (mostly 400
for any error), v2 maps the status to its codes. Helper errors without a status count as 400,
like v1. JS helpers are required with a typed signature at the top of each service.
- **Outcomes, not errors** (1.51.2): where an endpoint has several normal answers (scanning:
already used, consent missing…), v2 returns 200 with an `outcome` and keeps errors for real
failures. The service returns a union (`ScanResult`); `v1ScanResponse()` maps it to v1's statuses.
- **v1 statuses:** `ServiceError(status, message, code, { v1Status })` when v2 means something
different (wrong password: 401 in v2, 400 in v1); v1 controllers use `v1StatusOf(error, fallback)`.
- **Frontend v2 calls:** `apiV2("post", "/registrations/{id}/cancel", { params: { id }, authToken })`
(`frontend/src/lib/apiV2.ts`). After adding or changing a v2 route run `npm run api:types` in the
repo root and commit `frontend/src/lib/api-v2/` (`tests/v2.test.ts` fails while it's stale).
- **Shared middleware on v2** (1.51.3): `legacyErrorsToV2` (mounted on `/api/v2` before everything)
rewrites any `{ message, code }` error from middleware shared with v1 into the v2 shape, with the
old code as `reason`. New middleware can keep writing `{ message, code }`.
- **Deprecating v1:** when a v1 endpoint gets a v2 replacement, add it to `SUCCESSORS` in
`src/v2/deprecation.ts`.
- **Express 5:** route paths use path-to-regexp v8 (no `*`, `?` or regex in paths — use `{/:x}` for
optional parts, `/*name` for wildcards); rejected promises in handlers reach the error handler.
## Event picker and webhook guide (1.50.2)
- **Choosing an event:** use `EventPicker` (`components/shared/EventPicker.tsx`), not a `<select>`.
Give it the whole list (`/api/events/all?includePast=true`); it opens on upcoming events, keeps
past ones behind "Show past events", and searches all of them. `emptyLabel` adds a clearable
choice ("All events"), `graceHours` keeps just-ended events current (the scanner uses 12),
`describe` adds detail per row, and `defaultEventId(events)` picks the next upcoming one. In e2e,
`pickEvent(page, title)` from `e2e/fixtures.ts`.
- `/api/events/all` still returns full events (options, form, sessions) — fine for a few hundred;
a slim `?fields=` mode would help if a church gets into the thousands.
- **Webhook examples:** a new webhook event type needs an entry in `EXAMPLES` in
`utils/webhooks.js`; `tests/webhooks.test.js` fails if an example and `buildPayload` differ in
shape.
## Known gaps / follow-ups
+43 -10
View File
@@ -38,8 +38,10 @@ Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an
report** and 1.47.3 **season passes** shipped — §6 is done. 1.48.0 **outbound webhooks** shipped. 1.49.0 **dashboard (Today, Waiting for you) and Ctrl+K palette**
shipped; 1.49.1 **bulk actions** and 1.49.2 **saved filters** shipped — §9 is done. Languages
(Afrikaans) are removed from scope (2026-10-28), so everything after moves up by one. 1.50.0 **accessibility
pass** shipped. 1.50.1 **pagination and search on long lists** shipped. **Next: 1.51.0 TypeScript
backend / `/api/v2`.**
pass** shipped. 1.50.1 **pagination and search on long lists** and 1.50.2 **event picker and webhook guide** shipped. 1.51.0 **TypeScript backend and `/api/v2`
foundations**, 1.51.1 **v2 registrations and payments**, 1.51.2 **v2 accounts and check-in** and 1.51.3 **Express 5,
scanner on v2, v1 deprecation headers** shipped. 1.51.4 **security and dependency updates**, 1.51.5 **Next.js 16** and 1.51.6 **the complete command palette and
ExcelTS** shipped. **Next: 1.51.7 – 1.51.13 (every endpoint onto v2), then 2.0.0.**
**Repositories:**
@@ -879,11 +881,34 @@ an install (one leaked install would leak it for everyone), so:
the install secret like the heartbeat. The Hub adds the key, calls Claude, and returns
the result. Still **install → Hub only** — the Hub never reaches into an install.
- **The Hub meters every call** in rand: tokens used × the model's price × an exchange rate
Joshua sets in the Hub (with an optional margin). Usage is kept per install per month.
Joshua sets in the Hub × **(1 + markup)**. Usage is kept per install per month, as both the
real cost and the charged (marked-up) amount.
- **Markup** (decided 2026-10-29): one percentage set in the Hub (a default for every licence,
with an override per licence), e.g. 40%. It does two jobs: it covers Joshua if a month's real
usage runs a little over (exchange-rate moves, a last call that costs more than expected), and
it is his profit on AI. Churches only ever see the charged amount in rand. The real cost, the
markup and the profit are shown only in the Hub.
- **Monthly rand cap per licence**, set by Joshua in the Hub to match what the church pays
(e.g. R0 = AI off, R50, R200). The licence's cap is the first per-plan limit. At 80% the
install's admins get a warning email; at 100% the helpers are switched off until the next
month (or until Joshua raises the cap). Optional per-user daily limit against runaway use.
(e.g. R0 = AI off, R50, R200). The licence's cap is the first per-plan limit. The cap is
counted in **charged** rand, so the real spend can never be more than cap ÷ (1 + markup).
- **Hard stop, checked before each call:** the Hub refuses a call when *charged so far + the
worst case for this call* (its prompt plus the helper's `max_tokens`, priced with markup) would
pass the cap. A month can't run over by more than the markup already covers, even with
several people clicking at once (the check and the usage record happen in one transaction).
At 80% the install's admins get a warning email. Optional per-user daily limit against
runaway use.
- **The buttons follow the cap** (decided 2026-10-29):
- **Cap R0** (or AI off on the licence, or the install not connected to the Hub): the AI
buttons are **not shown at all**, anywhere.
- **Cap above R0, room left:** the buttons show and work.
- **Cap reached:** the buttons stay where they are but are **disabled**, with a tooltip like
"This month's AI allowance is used up — it renews on 1 December". They come back by
themselves next month, or as soon as Joshua raises the cap.
- The install learns this from `GET /api/v2/ai/status` → `{ enabled, capReached, renewsOn,
helpers }`. The answer is cached briefly from the Hub and refreshed after every call.
Every AI call also returns the new status, so the buttons disable straight after the call
that used up the cap. The backend checks again on each request, so a stale page can't get
past the cap.
- **Admins see their usage** in Site settings → AI: used this month (R and number of uses),
the cap, a daily chart, use per helper and per person, and an on/off switch per helper.
- If the Hub or Claude is unavailable, the buttons say so; nothing else in the app is affected.
@@ -904,7 +929,12 @@ an install (one leaked install would leak it for everyone), so:
- Install: `backend/src/ai/` — one client for the Hub's AI endpoint, one prompt + schema
per helper, usage shown from the Hub. Feature flag per helper, all off when the licence
has no AI or the Hub isn't connected.
has no AI or the Hub isn't connected. The AI endpoints exist **only in `/api/v2`**
(`/api/v2/ai/status`, `/api/v2/ai/<helper>`), built on 1.51's schemas and error format.
The frontend has one `useAiStatus()` hook and an `<AiButton>` that hides, enables or
disables itself from it.
- Hub: real cost and charged amount per call; the markup default and per-licence override;
a per-install month view with **cost, charged and profit**; the cap check before each call.
- Hub: the AI proxy, metering, caps, warnings, an exchange-rate setting, and a per-install
usage view for Joshua (alongside the existing install detail page).
- A small **eval set** (example event descriptions, reports, notes, with what a good answer
@@ -978,9 +1008,10 @@ income justify the hardware:
| 7 | 1.43.0 ✅ – 1.44.0 ✅ | **Multi-campus**: 1.43.0 campuses (settings, events, public filter and landing pages, home campus, filters, messages, summaries); 1.44.0 campus-scoped staff | Kids rooms and volunteer roles are per campus, so campuses come first; scoping is split out because it's security-heavy |
| 8 | 1.45.0 ✅ – 1.46.0 ✅ | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
| 9 | 1.47.0 ✅ – 1.47.3 ✅, 1.48.0 ✅ | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 ✅, 1.50.1 ✅ | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass, pagination and search on long lists (Afrikaans removed from scope) | Polish before the big release |
| 11 | 1.51.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 API removed, migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 ✅, 1.50.1 ✅, 1.50.2 ✅ | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass, pagination and search on long lists, event picker and webhook guide (Afrikaans removed from scope) | Polish before the big release |
| 11 | 1.51.0 ✅, 1.51.1 ✅, 1.51.2 ✅, 1.51.3 ✅ | TypeScript backend + OpenAPI + `/api/v2` alongside v1: 1.51.0 foundations (build, launcher, route declarations, errors, paging, OpenAPI at `/docs/v2`, first read endpoints); 1.51.1 registrations and payments as TS services, typed frontend calls; 1.51.2 check-in and accounts; 1.51.3 Express 5, the scanner on v2, v1 deprecation headers, v2-shaped shared middleware | The breaking-change groundwork |
| 11b | 1.51.4 ✅, 1.51.5 ✅, 1.51.6 ✅, 1.51.7 – 1.51.13 | **Every endpoint on v2 before 2.0** (decided 2026-10-29): 1.51.4 dependency audit and Node 24 LTS; 1.51.5 Next.js 16 and ESLint 10 (ESLint 9 is out of support); 1.51.6 the complete command palette and ExcelTS instead of exceljs; 1.51.7 events and event set-up (events, sections, costs, uploads, series, season passes, bundles, coupons, campuses); 1.51.8 registrations and money, staff side (manage registrations, payments, cashup, provider transactions, waitlist, sponsorship, consent, forms, feedback); 1.51.9 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.10 kids check-in and volunteer rosters; 1.51.11 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.12 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.13 **incoming webhooks and links in sent messages**: Yoco, PayFast and WAWP webhooks and the links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds, waitlist offers) get v2 URLs — new messages and provider registrations use them, and the v1 paths keep answering (links by a 308 redirect to v2, provider webhooks handled by the same code, since providers may not follow a redirect on a POST). Each moves its area's logic into TS services, its screens onto `apiV2`, and its v1 routes into `SUCCESSORS` | 2.0 can then remove v1 |
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 removed (all of it is on v2 by then; the old webhook URLs and links already sent in messages keep forwarding to v2), migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
Each phase should add matching help content (`frontend/src/content/help/`),
audit log entries, Jest tests, a CHANGELOG entry, and updates to FEATURES.md
@@ -1017,6 +1048,8 @@ feature.
| — | AI in 2.0 | The **headline feature of 2.0**: one-button helpers only (draft an event, summarise reports and feedback, draft messages, improve text) — no chat yet (2026-10-24) |
| — | AI provider and key | One provider, **Claude** (Haiku 5.5 to start, for cost). Joshua provides the key; installs call AI **through the Hub**, which holds the key and meters usage (2026-10-24) |
| — | AI cost limits | A **monthly rand cap per install**, set in the Hub per licence to match what the church pays; admins see their usage in Site settings → AI (2026-10-24) |
| — | v1 in 2.0 | **Every endpoint moves to v2 before 2.0** (1.51.6 – 1.51.11, by area), then 2.0 removes v1. Changed the same day from "retire only what's replaced". Incoming webhooks (Yoco, PayFast, WAWP) and links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds) also get v2 URLs (1.51.13); their v1 paths keep working — links redirect (308) to v2, provider webhooks are handled by the same code — because old messages and provider registrations still use them (2026-10-29) |
| — | AI buttons and markup | AI buttons show only when the licence's monthly cap is **above R0**. When the cap is reached they stay visible but **disabled** until next month or until the cap is raised. Usage is charged at the real cost **plus a markup %** set in the Hub (a default, plus an override per licence). The markup covers small overruns and is the profit. The cap is counted in charged rand, and each call is checked against its worst-case cost before it's sent (2026-10-29) |
| — | AI later | Ask AI (read-only, tools only), help/navigation assistant, actions with confirmation, more helpers, and long-term **self-hosted** AI behind the same Hub endpoint — not tied to versions (2026-10-24) |
| — | POPIA: message kinds | Bulk messages are either **Event updates** (to registrants — still sent after unsubscribing) or **News & announcements** (marketing — opt-in, one-click unsubscribe). Every bulk email links to the preferences (2026-10-23) |
| — | POPIA: existing users | Kept opted in to news (logged as "existing account"), asked once at their next login to confirm or opt out. New sign-ups choose with an unticked box (2026-10-23) |
+3 -5
View File
@@ -1,6 +1,6 @@
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { test, expect, seed, loginAs } from "./fixtures";
import { test, expect, seed, loginAs, pickEvent } from "./fixtures";
// WCAG 2.2 AA checks (axe-core) on the pages visitors and members use, so the
// accessibility pass (1.50.0) doesn't regress.
@@ -75,8 +75,7 @@ test.describe("accessibility (WCAG 2.2 AA)", () => {
await page.getByRole("button", { name: "Sign In" }).click();
await expect(page.getByText("Signed in as supervisor")).toBeVisible();
await check(page, "kiosk event picker");
const value = await page.locator("option", { hasText: "E2E Free Evening" }).getAttribute("value");
await page.locator("select").selectOption(value!);
await pickEvent(page, "E2E Free Evening");
await page.getByRole("button", { name: "Start Kiosk" }).click();
await expect(page.getByRole("heading", { name: "E2E Free Evening" })).toBeVisible();
await check(page, "kiosk form");
@@ -106,8 +105,7 @@ test.describe("keyboard", () => {
await page.getByLabel("Email").fill(seed().users.supervisor.email);
await page.getByLabel("Password", { exact: true }).fill("E2e-password-123");
await page.getByRole("button", { name: "Sign In" }).click();
const value = await page.locator("option", { hasText: "E2E Free Evening" }).getAttribute("value");
await page.getByLabel("Event").selectOption(value!);
await pickEvent(page, "E2E Free Evening");
await page.getByRole("button", { name: "Start Kiosk" }).click();
const opener = page.getByRole("button", { name: "Change Event" });
+104
View File
@@ -0,0 +1,104 @@
import { test, expect, seed, API } from "./fixtures";
// /api/v2 (1.51.0) against the real server and database: paged public events, the v2 error
// shape, the signed-in person, and the OpenAPI reference at /docs/v2.
test.describe("API v2", () => {
test("public events come a page at a time; errors have a code", async ({ request }) => {
const res = await request.get(`${API}/api/v2/events?limit=2`);
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.data).toHaveLength(2);
expect(body.meta).toMatchObject({ page: 1, limit: 2 });
expect(body.meta.total).toBeGreaterThan(2);
const search = await (await request.get(`${API}/api/v2/events?q=free%20evening`)).json();
expect(search.data.map((e: { title: string }) => e.title)).toEqual(["E2E Free Evening"]);
const one = await (await request.get(`${API}/api/v2/events/${seed().events.paid}`)).json();
expect(one.data.ticketOptions.length).toBeGreaterThan(0);
const bad = await request.get(`${API}/api/v2/events?limit=1000`);
expect(bad.status()).toBe(400);
expect((await bad.json()).error).toMatchObject({ code: "VALIDATION_FAILED", fields: { limit: expect.any(String) } });
const missing = await request.get(`${API}/api/v2/events/00000000-0000-4000-8000-000000000000`);
expect((await missing.json()).error.code).toBe("NOT_FOUND");
});
test("me and my registrations need a token", async ({ request }) => {
expect((await request.get(`${API}/api/v2/me`)).status()).toBe(401);
const headers = { Authorization: `Bearer ${seed().users.user.token}` };
const me = await (await request.get(`${API}/api/v2/me`, { headers })).json();
expect(me.data).toMatchObject({ id: seed().users.user.id, role: "user" });
const regs = await (await request.get(`${API}/api/v2/me/registrations?past=true`, { headers })).json();
expect(regs.meta).toMatchObject({ page: 1, limit: 25 });
expect(regs.data.length).toBe(Math.min(25, regs.meta.total));
});
test("register for a free event, then cancel it", async ({ request }) => {
const headers = { Authorization: `Bearer ${seed().users.staff.token}` };
const event = (await (await request.get(`${API}/api/v2/events/${seed().events.free}`)).json()).data;
const made = await request.post(`${API}/api/v2/registrations`, { headers, data: { eventId: event.id, options: [{ eventOptionId: event.ticketOptions[0].id, quantity: 1 }] } });
expect(made.status()).toBe(201);
const reg = (await made.json()).data;
expect(reg).toMatchObject({ status: "paid", balance: 0, event: { id: event.id } });
const someoneElse = await request.post(`${API}/api/v2/registrations/${reg.id}/cancel`, { headers: { Authorization: `Bearer ${seed().users.user.token}` } });
expect((await someoneElse.json()).error.code).toBe("NOT_FOUND");
const cancelled = await request.post(`${API}/api/v2/registrations/${reg.id}/cancel`, { headers });
expect((await cancelled.json()).data.status).toBe("cancelled");
const again = await request.post(`${API}/api/v2/registrations/${reg.id}/cancel`, { headers });
expect((await again.json()).error).toMatchObject({ code: "VALIDATION_FAILED", message: "Registration is already cancelled" });
});
test("log in, change my details, and check a ticket in", async ({ request }) => {
const bad = await request.post(`${API}/api/v2/auth/login`, { data: { identifier: seed().users.user.email, password: "wrong-password" } });
expect(bad.status()).toBe(401);
expect((await bad.json()).error).toMatchObject({ code: "UNAUTHENTICATED", message: "Invalid email or password" });
const login = await (await request.post(`${API}/api/v2/auth/login`, { data: { identifier: seed().users.user.email, password: "E2e-password-123" } })).json();
expect(login.data).toMatchObject({ status: "signed_in", user: { id: seed().users.user.id, role: "user" } });
const headers = { Authorization: `Bearer ${login.data.token}` };
const changed = await (await request.patch(`${API}/api/v2/me`, { headers, data: { notificationPreference: "email" } })).json();
expect(changed.data).toMatchObject({ id: seed().users.user.id, notificationPreference: "email" });
expect(changed.token).toEqual(expect.any(String));
// A free ticket for the user, then check it in twice through v2.
const event = (await (await request.get(`${API}/api/v2/events/${seed().events.free}`)).json()).data;
await request.post(`${API}/api/v2/registrations`, { headers, data: { eventId: event.id, options: [{ eventOptionId: event.ticketOptions[0].id, quantity: 1 }] } });
const tickets = await (await request.get(`${API}/api/tickets/mytickets`, { headers })).json();
const qr = tickets.find((t: { eventId: string; qrCode: string }) => t.eventId === event.id).qrCode;
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
expect((await request.post(`${API}/api/v2/checkin/${qr}`, { headers, data: {} })).status()).toBe(403);
const look = await (await request.get(`${API}/api/v2/checkin/${qr}`, { headers: staff })).json();
expect(look.data).toMatchObject({ outcome: "found", ticket: { eventId: event.id } });
expect(look.data.ticket.remaining).toBeGreaterThan(0);
const scanned = await (await request.post(`${API}/api/v2/checkin/${qr}`, { headers: staff, data: { eventId: event.id } })).json();
expect(scanned.data).toMatchObject({ outcome: "checked_in", remaining: 0 });
const again = await (await request.post(`${API}/api/v2/checkin/${qr}`, { headers: staff, data: {} })).json();
expect(again.data).toMatchObject({ outcome: "already_used", checkedIn: 0 });
expect((await (await request.post(`${API}/api/v2/checkin/NOT-A-CODE`, { headers: staff, data: {} })).json()).error.code).toBe("NOT_FOUND");
});
test("v1 endpoints with a v2 version say so", async ({ request }) => {
const v1 = await request.get(`${API}/api/events`);
expect(v1.headers()["deprecation"]).toMatch(/^@\d+$/);
expect(v1.headers()["link"]).toBe(`</api/v2/events>; rel="successor-version"`);
expect((await request.get(`${API}/api/events/all`, { headers: { Authorization: `Bearer ${seed().users.staff.token}` } })).headers()["deprecation"]).toBeUndefined();
});
test("an admin can read the OpenAPI reference at /docs/v2", async ({ page, request }) => {
const headers = { Authorization: `Bearer ${seed().users.user.token}` };
expect((await request.get(`${API}/api/v2/openapi.json`, { headers })).status()).toBe(403);
await page.goto(`${API}/docs/v2?token=${seed().users.admin.token}`);
await expect(page.getByRole("heading", { name: /Cross Events API/ })).toBeVisible();
await expect(page.locator(".opblock-summary-path", { hasText: "/events/{id}" })).toBeVisible();
// The token isn't left in the address bar.
expect(page.url()).not.toContain("token=");
});
});
+41
View File
@@ -1,5 +1,18 @@
import fs from "fs";
import path from "path";
import { test, expect, loginAs } from "./fixtures";
// Dashboard pages the palette leaves out on purpose: role home pages it reaches through
// "Dashboard", pages that only redirect or reuse another, and an older form nothing links to.
const NOT_IN_PALETTE = new Set([
"/dashboard/admin", "/dashboard/staff", "/dashboard/supervisor", // role homes ("Dashboard")
"/dashboard/admin/settings", // its tabs are listed one by one
"/dashboard/admin/whatsapp", "/dashboard/supervisor/sections", "/dashboard/admin/forms", // redirects / the same page
"/dashboard/admin/cashup/[id]", // one event's cashup, from Cashup
"/dashboard/supervisor/manual-registration", // the older manual form
"/dashboard/user/reset-password", // from My profile & security
]);
// Dashboard "Today" and "Waiting for you", and the Ctrl+K command palette (1.49.0).
test.describe("dashboard and command palette", () => {
test("the admin dashboard shows what's on today and what's waiting", async ({ page }) => {
@@ -39,6 +52,8 @@ test.describe("dashboard and command palette", () => {
// Staff don't get admin pages.
await loginAs(page, "staff");
await page.goto("/dashboard/staff");
// The shortcut listens once the signed-in user has loaded (the search button shows then).
await expect(page.getByRole("button", { name: /Search or jump to/ })).toBeVisible();
await page.keyboard.press("Control+k");
const staffDialog = page.getByRole("dialog", { name: "Go to or search" });
await staffDialog.getByRole("combobox", { name: "Search" }).fill("users");
@@ -46,4 +61,30 @@ test.describe("dashboard and command palette", () => {
await staffDialog.getByRole("combobox", { name: "Search" }).fill("scan");
await expect(staffDialog.getByRole("option", { name: "Scan tickets" })).toBeVisible();
});
test("the palette lists every report and every dashboard page", async ({ page }) => {
const root = path.join(__dirname, "..", "frontend", "src");
const palette = fs.readFileSync(path.join(root, "lib", "commandPages.ts"), "utf8");
const pages: string[] = [];
const walk = (dir: string) => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (e.isDirectory()) walk(path.join(dir, e.name));
else if (e.name === "page.tsx") pages.push("/" + path.relative(path.join(root, "app"), dir).split(path.sep).join("/"));
}
};
walk(path.join(root, "app", "dashboard"));
const missing = pages.filter(p => !NOT_IN_PALETTE.has(p) && !palette.includes(`href: "${p}"`) && !palette.includes(`href: "${p}?`));
expect(missing).toEqual([]);
// Reports come from the catalogue: one the old hand-made list didn't have.
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor");
await expect(page.getByRole("button", { name: /Search or jump to/ })).toBeVisible();
await page.keyboard.press("Control+k");
const dialog = page.getByRole("dialog", { name: "Go to or search" });
await dialog.getByRole("combobox", { name: "Search" }).fill("medical");
await expect(dialog.getByRole("option", { name: /Report: Medical & emergency list/ })).toBeVisible();
await dialog.getByRole("combobox", { name: "Search" }).fill("profit");
await expect(dialog.getByRole("option", { name: /Report: Profit report/ })).toBeVisible();
});
});
+52
View File
@@ -0,0 +1,52 @@
import AxeBuilder from "@axe-core/playwright";
import { test, expect, loginAs, api } from "./fixtures";
// 1.50.2: event dropdowns are a searchable picker — upcoming events first, past ones a click
// away, and typing searches them all.
test.describe("event picker", () => {
test("opens on upcoming events; past ones are behind a button or found by searching", async ({ page }) => {
const start = new Date(Date.now() - 40 * 864e5);
await api(page, "supervisor", "POST", "/api/events", {
title: "E2E Picker Old Gathering", description: "A past event for the picker test.", price: 0, location: "1 Test Street, Pretoria",
startDate: start.toISOString(), endDate: new Date(start.getTime() + 2 * 3600e3).toISOString(),
});
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/email-attendees");
await page.getByRole("button", { name: /^Event: Select an event/ }).click();
const list = page.getByRole("listbox", { name: "Events" });
await expect(list.getByRole("option", { name: /E2E Free Evening/ })).toBeVisible();
await expect(list.getByRole("option", { name: /E2E Picker Old Gathering/ })).toHaveCount(0);
// The open picker passes the WCAG 2.2 AA checks.
const axe = await new AxeBuilder({ page }).include("[data-testid=event-picker]").withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]).analyze();
expect(axe.violations.map((v) => `${v.id}: ${v.nodes.map((n) => n.target.join(" ")).join(", ")}`)).toEqual([]);
await page.getByRole("button", { name: /^Show past events/ }).click();
await expect(list.getByRole("option", { name: /E2E Picker Old Gathering/ })).toBeVisible();
await page.getByRole("button", { name: "Hide past events" }).click();
// Searching finds past events too; the keyboard chooses.
const search = page.getByRole("combobox", { name: "Search events" });
await search.fill("old gathering");
await expect(list.getByRole("option")).toHaveCount(1);
await search.press("Enter");
await expect(page.getByRole("button", { name: /^Event: E2E Picker Old Gathering/ })).toBeFocused();
// Escape closes without changing anything.
await page.keyboard.press("ArrowDown");
await expect(list).toBeVisible();
await page.keyboard.press("Escape");
await expect(list).toBeHidden();
await expect(page.getByRole("button", { name: /^Event: E2E Picker Old Gathering/ })).toBeVisible();
});
test("the reports event list can be searched", async ({ page }) => {
await loginAs(page, "admin");
await page.goto("/dashboard/supervisor/reports");
await page.getByRole("button", { name: /^Events:/ }).click();
await page.getByRole("button", { name: "Clear", exact: true }).click();
await page.getByLabel("Search events").fill("alpha");
await page.getByRole("button", { name: /^Select these 1$/ }).click();
await expect(page.getByRole("button", { name: "Events: E2E Alpha Course" })).toBeVisible();
});
});
+48
View File
@@ -0,0 +1,48 @@
import path from "path";
import { test, expect, seed, API } from "./fixtures";
// Excel exports (ExcelTS since 1.51.6): a styled report and the attendance register come back as
// real workbooks that open and hold what was asked for.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { Workbook } = require(path.join(__dirname, "..", "backend", "node_modules", "@cj-tech-master", "excelts"));
const XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
async function open(body: Buffer) {
const wb = new Workbook();
await wb.xlsx.load(body);
return wb;
}
test.describe("Excel exports", () => {
test("a report with stats, a chart and a table", async ({ request }) => {
const res = await request.post(`${API}/api/reports/excel`, {
headers: { Authorization: `Bearer ${seed().users.supervisor.token}` },
data: {
title: "E2E Payments",
kind: "table",
subtitle: "1–31 October",
stats: [{ label: "Total", value: "R 1 200.00" }],
chart: { title: "By method", data: [{ label: "Card", value: 900 }, { label: "Cash", value: 300 }] },
table: { columns: ["Name", "Amount"], rows: [["Thandi", "R 600.00"], ["Sipho", "R 600.00"]] },
},
});
expect(res.status()).toBe(200);
expect(res.headers()["content-type"]).toContain(XLSX);
const ws = (await open(await res.body())).worksheets[0];
const values: string[] = [];
ws.eachRow((row: { values: unknown[] }) => values.push(...row.values.filter(Boolean).map(String)));
expect(values).toEqual(expect.arrayContaining(["E2E Payments", "Total", "Card", "Thandi", "Sipho"]));
});
test("the attendance register", async ({ request }) => {
const res = await request.get(`${API}/api/attendance/${seed().events.course}/export?format=xlsx`, {
headers: { Authorization: `Bearer ${seed().users.supervisor.token}` },
});
expect(res.status()).toBe(200);
expect(res.headers()["content-type"]).toContain(XLSX);
const ws = (await open(await res.body())).getWorksheet("Attendance");
expect(ws.getRow(1).getCell(1).value).toBe("Name");
expect(ws.rowCount).toBeGreaterThan(1);
});
});
+8
View File
@@ -51,4 +51,12 @@ export async function api<T = unknown>(page: Page, role: Role, method: string, u
return (text ? JSON.parse(text) : null) as T;
}
/** Chooses an event in an EventPicker (the first on the page, or the one labelled `label`) by searching its title. */
export async function pickEvent(page: Page, title: string, label?: string) {
const button = label ? page.getByRole("button", { name: new RegExp(`^${label}:`) }) : page.getByTestId("event-picker").first().getByRole("button").first();
await button.click();
await page.getByRole("combobox", { name: /^Search/ }).fill(title);
await page.getByRole("option", { name: new RegExp(title) }).first().click();
}
export { expect };
+2 -2
View File
@@ -1,4 +1,4 @@
import { test, expect, seed, loginAs } from "./fixtures";
import { test, expect, seed, loginAs, pickEvent } from "./fixtures";
// Kids check-in (1.45.0): a supervisor sets it up, a parent adds a child, the desk checks the child
// in (pickup code + sticker panel), the parent sees the code, and a wrong code is refused before
@@ -7,7 +7,7 @@ test.describe.serial("kids check-in", () => {
test("a supervisor sets up the kids ticket, a room and switches it on", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/kids");
await page.getByRole("combobox").first().selectOption(seed().events.kids);
await pickEvent(page, "E2E Kids Church");
await page.getByRole("button", { name: /Add a free "Kids check-in" ticket/ }).click();
await expect(page.getByLabel("Kids ticket")).not.toHaveValue("");
await page.getByRole("button", { name: /Add "Kids rooms"/ }).click();
+3 -4
View File
@@ -1,4 +1,4 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
import { test, expect, seed, loginAs, api, pickEvent } from "./fixtures";
type Registration = { id: string; eventId: string; user?: { name: string }; registrationOptions: { sessionId?: string | null; sessionIds?: string[]; priceSnapshot: number; quantity: number }[] };
type WaitlistEntry = { eventId: string; sessionId: string | null; quantity: number };
@@ -67,12 +67,11 @@ test.describe("pick sessions", () => {
test("at the door: book a class for a walk-in", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/at-the-door");
const value = await page.locator("option", { hasText: "E2E Drop-in Class" }).getAttribute("value");
// The page picks a default event once its list loads; choose ours after that.
await page.waitForLoadState("networkidle");
await page.locator("select").first().selectOption(value!);
await pickEvent(page, "E2E Drop-in Class");
await page.waitForLoadState("networkidle");
await expect(page.locator("select").first()).toHaveValue(value!);
await expect(page.getByRole("button", { name: /^Event: E2E Drop-in Class/ })).toBeVisible();
await page.getByPlaceholder("Name, email or phone…").fill("Door Walk In");
await page.getByText('New attendee "Door Walk In"…').click();
+2 -2
View File
@@ -1,4 +1,4 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
import { test, expect, seed, loginAs, api, pickEvent } from "./fixtures";
test.describe("multi-day events", () => {
test("check-in is once per session, and the register and certificates follow", async ({ page }) => {
@@ -44,7 +44,7 @@ test.describe("multi-day events", () => {
test("the scanner offers today's session for a multi-day event", async ({ page }) => {
await loginAs(page, "staff");
await page.goto("/dashboard/staff/ticket-scanning");
await page.locator("select").first().selectOption({ label: "E2E Alpha Course" });
await pickEvent(page, "E2E Alpha Course");
await expect(page.locator("select", { has: page.locator("option", { hasText: "Today's session" }) })).toBeVisible();
await expect(page.getByText("Today's session (Week 2)")).toBeAttached();
});
+2 -3
View File
@@ -1,4 +1,4 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
import { test, expect, seed, loginAs, api, pickEvent } from "./fixtures";
type EventRow = { id: string; title: string; scheduleType?: string; sessions?: { cancelled?: boolean }[] };
@@ -80,8 +80,7 @@ test.describe("supervisor tools", () => {
await page.getByPlaceholder("••••••••").fill("E2e-password-123");
await page.getByRole("button", { name: "Sign In" }).click();
await expect(page.getByText("Signed in as supervisor")).toBeVisible();
const value = await page.locator("option", { hasText: "E2E Free Evening" }).getAttribute("value");
await page.locator("select").selectOption(value!);
await pickEvent(page, "E2E Free Evening");
await page.getByRole("button", { name: "Start Kiosk" }).click();
await expect(page.getByRole("heading", { name: "E2E Free Evening" })).toBeVisible();
+16
View File
@@ -67,4 +67,20 @@ test.describe.serial("outbound webhooks", () => {
await endpoint.getByRole("button", { name: "Delivery log" }).click();
await expect(endpoint.getByTestId("webhook-delivery").filter({ hasText: "user.created" })).toContainText("succeeded");
});
test("the guide explains the signature and shows an example of each event", async ({ page }) => {
await loginAs(page, "admin");
await page.goto("/dashboard/admin/settings?tab=webhooks");
const guide = page.getByTestId("webhook-guide");
const toggle = guide.getByRole("button", { name: /How webhooks work/ });
await expect(toggle).toBeVisible();
if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click();
await expect(guide).toContainText("X-CrossEvents-Signature");
await guide.getByRole("button", { name: /registration.paid/ }).click();
const example = guide.getByTestId("webhook-example-registration.paid");
await expect(example).toContainText("\"type\": \"registration.paid\"");
await expect(example).toContainText("\"amountPaid\"");
await guide.getByRole("button", { name: "PHP" }).click();
await expect(guide).toContainText("hash_hmac");
});
});
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
## This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+23 -13
View File
@@ -1,16 +1,26 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
// Next.js 16's own flat configs (no more FlatCompat / eslintrc). Run with `npm run lint`
// (`next lint` was removed in Next 16).
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
{
// React Compiler-era rules that eslint-plugin-react-hooks 7 (with Next 16) turns on as
// errors. They flag patterns the app uses on purpose and that work (mostly loading data in
// useEffect), so they're warnings while the code is moved off them screen by screen —
// see HANDOVER, "Next.js 16". New code should follow them.
rules: {
"react-hooks/set-state-in-effect": "warn",
"react-hooks/purity": "warn",
"react-hooks/immutability": "warn",
"react-hooks/static-components": "warn",
"react-hooks/refs": "warn",
},
},
globalIgnores([".next/**", ".next-*/**", "out/**", "build/**", "next-env.d.ts", "src/lib/api-v2/schema.d.ts"]),
]);
export default eslintConfig;
+2 -1
View File
@@ -6,7 +6,8 @@ const nextConfig: NextConfig = {
// from clashing with one already running.
distDir: process.env.NEXT_DIST_DIR || ".next",
images: {
domains: ['images.unsplash.com'],
// (images.domains is deprecated in Next 16.)
remotePatterns: [{ protocol: 'https', hostname: 'images.unsplash.com' }],
},
compiler: {
removeConsole: process.env.NODE_ENV === 'production',
+1125 -671
View File
File diff suppressed because it is too large Load Diff
+14 -12
View File
@@ -1,12 +1,13 @@
{
"name": "hope-events-frontend",
"version": "1.50.1",
"version": "1.51.6",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "eslint src",
"api:types": "openapi-typescript src/lib/api-v2/openapi.json -o src/lib/api-v2/schema.d.ts"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -28,33 +29,34 @@
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-tooltip": "^1.2.7",
"@sentry/nextjs": "^10.71.0",
"@sentry/nextjs": "^10.78.0",
"@zxing/browser": "^0.1.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"idb": "^8.0.4",
"lucide-react": "^0.536.0",
"next": "^15.5.24",
"next": "^16.4.3",
"qrcode": "^1.5.4",
"react": "19.1.0",
"react": "^19.3.1",
"react-day-picker": "^9.8.1",
"react-dom": "19.1.0",
"react-dom": "^19.3.1",
"react-hook-form": "^7.62.0",
"react-webcam": "^7.2.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.0.15"
"zod": "^4.6.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/node": "^20",
"@types/qrcode": "^1.5.5",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"autoprefixer": "^10.4.21",
"eslint": "^9",
"eslint-config-next": "^15.5.24",
"eslint": "^10.13.0",
"eslint-config-next": "^16.4.3",
"openapi-typescript": "^7.13.0",
"postcss": "^8.5.6",
"tailwindcss": "3.4",
"typescript": "^5"
@@ -11,6 +11,7 @@ import type { Event, FormResponse, Registration } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { RegistrationBulkBar } from "@/components/admin/RegistrationBulkBar";
import { SavedFilters } from "@/components/shared/SavedFilters";
import { EventPicker } from "@/components/shared/EventPicker";
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
@@ -265,13 +266,8 @@ export default function AdminRegistrationsPage() {
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded px-2 py-1.5 text-sm max-w-48" value={eventFilter} onChange={e => setEventFilter(e.target.value)}>
<option value="">All events</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<label className="block text-xs text-gray-600 mb-1" htmlFor="registrations-event">Event</label>
<EventPicker id="registrations-event" className="min-w-[14rem]" events={events} value={eventFilter} onChange={setEventFilter} emptyLabel="All events" />
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={includePastEvents} onChange={e => setIncludePastEvents(e.target.checked)} />
@@ -12,6 +12,7 @@ import { Ticket } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event, Ticket as TicketRecord } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
function EventTicketsContent() {
const { token, user } = useAuth();
@@ -40,7 +41,7 @@ function EventTicketsContent() {
(async () => {
try {
if (!token) return;
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token });
const now = Date.now();
const active = (evs || []).filter((ev) => {
const t = new Date(ev.endDate).getTime();
@@ -169,12 +170,14 @@ function EventTicketsContent() {
)}
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2 mb-4">
<label className="text-sm text-gray-700 shrink-0">Event</label>
<select
className="border rounded px-3 py-2 w-full sm:w-64 max-w-full"
<label className="text-sm text-gray-700 shrink-0" htmlFor="tickets-event">Event</label>
<EventPicker
id="tickets-event"
className="w-full sm:w-72"
events={events}
emptyLabel="All events"
value={eventId}
onChange={(e) => {
const v = e.target.value;
onChange={(v) => {
setEventId(v);
setSelectedUserId("");
router.replace(
@@ -185,14 +188,7 @@ function EventTicketsContent() {
}`
);
}}
>
<option value="">All events</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>
{ev.title}
</option>
))}
</select>
/>
<label className="text-sm text-gray-700 sm:ml-4 shrink-0">User</label>
<select
@@ -11,11 +11,13 @@ import { OfflineScanPanel } from "@/components/scanner/OfflineScanPanel";
import type { SnapshotTicket } from "@/lib/offlineScan";
import { errorMessage } from "@/lib/utils";
import type { Event, RecentScan, ScanStats, Section } from "@/types";
import { EventPicker } from "@/components/shared/EventPicker";
import { activeSessions, currentSessionLabel, formatSessionWhen, sessionLabel } from "@/lib/sessions";
import { BackLink } from "@/components/shared/BackLink";
import { apiV2 } from "@/lib/apiV2";
// GET /api/tickets/scan-preview/:qr — just the fields the confirm step needs. Offline
// scans build the same shape from the downloaded ticket list.
// What the confirm step needs, from GET /api/v2/checkin/{qrCode}. Offline scans build the
// same shape from the downloaded ticket list.
type ScanTicket = {
id: string;
eventId?: string;
@@ -72,7 +74,6 @@ export default function TicketScanningPage() {
// Event selection state
const [allEvents, setAllEvents] = useState<Event[]>([]);
const [includePastEvents, setIncludePastEvents] = useState(false);
const [selectedEventId, setSelectedEventId] = useState<string>("all");
const [loadingEvents, setLoadingEvents] = useState<boolean>(false);
const [sections, setSections] = useState<Section[]>([]);
@@ -98,14 +99,12 @@ export default function TicketScanningPage() {
})();
}, [token]);
const events = useMemo(() => {
if (includePastEvents) return allEvents;
const cutoff = Date.now() - 12 * 60 * 60 * 1000;
return allEvents.filter(ev => {
const t = new Date(ev.endDate).getTime();
return !isNaN(t) && t >= cutoff;
});
}, [allEvents, includePastEvents]);
// The picker: every event, with the offline-locked one even if it has gone from the list.
const pickerEvents = useMemo(() => {
const lock = offline.lock;
if (!lock || allEvents.some(ev => ev.id === lock.eventId)) return allEvents;
return [...allEvents, { id: lock.eventId, title: lock.eventTitle, startDate: new Date().toISOString() } as Event];
}, [allEvents, offline.lock]);
const selectedEvent = useMemo(() => allEvents.find(ev => ev.id === selectedEventId) || null, [allEvents, selectedEventId]);
const eventSessions = useMemo(() => selectedEvent?.scheduleType === "sessions" ? activeSessions(selectedEvent.sessions) : [], [selectedEvent]);
@@ -189,7 +188,21 @@ export default function TicketScanningPage() {
// Uses the lightweight scan-preview endpoint (only fetches fields needed for the confirm modal)
let preview: ScanTicket;
try {
preview = await apiFetch<ScanTicket>(`/api/tickets/scan-preview/${encodeURIComponent(text)}${sessionParam ? `?sessionId=${encodeURIComponent(sessionParam)}` : ""}`, { authToken: token });
const { data } = await apiV2("get", "/checkin/{qrCode}", { params: { qrCode: text }, query: sessionParam ? { sessionId: sessionParam } : {}, authToken: token });
// A ticket that was handed on: the message says who has it now.
if (data.outcome === "transferred" || !data.ticket) {
setErrorModal(data.message || "This ticket has been transferred.");
return;
}
const t = data.ticket;
preview = {
id: t.id, eventId: t.eventId, eventOptionId: t.ticketOptionId || undefined, quantity: t.quantity, consentStatus: t.consentStatus,
event: { id: t.eventId, title: t.eventTitle || undefined },
registrationOption: { eventOption: { id: t.ticketOptionId || undefined, name: t.ticketName || undefined }, variant: t.variant ? { name: t.variant } : null },
attendee: t.attendee ? { name: t.attendee } : null,
user: { name: t.bookedBy || undefined },
remaining: t.remaining, session: t.session, sessionError: t.sessionError,
};
} catch (pe) {
setErrorModal(errorMessage(pe) || 'Failed to validate ticket');
return;
@@ -282,38 +295,34 @@ export default function TicketScanningPage() {
return;
}
// Background write — reverse the optimistic update on failure
// Background write — reverse the optimistic update unless it checked in
const undo = () => { setSuccessModal(null); setScanInfo(null); };
try {
const url = `/api/tickets/scan/${encodeURIComponent(qrCode)}${
selectedEventId && selectedEventId !== "all" ? `?eventId=${encodeURIComponent(selectedEventId)}` : ''
}`;
await apiFetch(url, {
method: "POST",
const { data } = await apiV2("post", "/checkin/{qrCode}", {
params: { qrCode },
authToken: token,
body: {
qty,
people: qty,
deviceId: offline.deviceId(),
...(selectedEventId && selectedEventId !== "all" ? { eventId: selectedEventId } : {}),
...(selectedSectionId && selectedSectionId !== "all" ? { sectionId: selectedSectionId } : {}),
...(ticket?.session ? { sessionId: ticket.session.id } : {}),
...(overrideConsent ? { overrideConsent: true } : {}),
},
});
refreshStatsAndRecent();
} catch (e) {
setSuccessModal(null);
setScanInfo(null);
const raw = errorMessage(e) || "Failed to scan ticket";
try {
const parsed = JSON.parse(raw);
const msg: string | undefined = parsed?.message;
if (msg && /already\s*(been)?\s*used/i.test(msg)) {
setAlreadyUsedModal({ message: msg, ticket: parsed?.ticket });
return;
}
setErrorModal(msg || raw);
} catch {
setErrorModal(raw);
if (data.outcome === "checked_in") {
refreshStatsAndRecent();
} else if (data.outcome === "already_used") {
// Someone else scanned it in the meantime.
undo();
setAlreadyUsedModal({ message: data.message, ticket });
} else {
undo();
setErrorModal(data.message);
}
} catch (e) {
undo();
setErrorModal(errorMessage(e) || "Failed to scan ticket");
}
}
@@ -360,23 +369,18 @@ export default function TicketScanningPage() {
{/* Event filter */}
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2 mb-3">
<label className="text-sm text-gray-700 shrink-0">Event</label>
<select
className="border rounded px-3 py-2 w-full sm:w-56 max-w-full"
value={selectedEventId}
onChange={(e) => setSelectedEventId(e.target.value)}
<label className="text-sm text-gray-700 shrink-0" htmlFor="scan-event">Event</label>
{/* Events that ended in the last 12 hours still count as current (late scanning). */}
<EventPicker
id="scan-event"
className="w-full sm:w-72"
events={pickerEvents}
value={selectedEventId === "all" ? "" : selectedEventId}
onChange={id => setSelectedEventId(id || "all")}
emptyLabel="All events"
graceHours={12}
disabled={!!offline.lock}
>
<option value="all">All events</option>
{offline.lock && !events.some(ev => ev.id === offline.lock!.eventId) && <option value={offline.lock.eventId}>{offline.lock.eventTitle}</option>}
{events.map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer">
<input type="checkbox" checked={includePastEvents} onChange={e => setIncludePastEvents(e.target.checked)} />
Include past events
</label>
/>
{loadingEvents && <span className="text-xs text-gray-500">Loading events…</span>}
</div>
{sections.length > 0 && (
@@ -418,7 +422,7 @@ export default function TicketScanningPage() {
offline={offline}
token={token}
eventId={selectedEventId}
eventTitle={events.find(ev => ev.id === selectedEventId)?.title || offline.lock?.eventTitle || ""}
eventTitle={allEvents.find(ev => ev.id === selectedEventId)?.title || offline.lock?.eventTitle || ""}
sectionId={selectedSectionId !== "all" ? selectedSectionId : null}
sectionName={sections.find(s => s.id === selectedSectionId)?.name || null}
onError={setError}
@@ -4,13 +4,13 @@ import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { BackLink } from "@/components/shared/BackLink";
import { RosterSheet } from "@/components/volunteers/RosterSheet";
import { CheckInQr } from "@/components/volunteers/CheckInQr";
import { HandHeart } from "lucide-react";
import type { RosterEvent } from "@/lib/volunteers";
import { EventPicker } from "@/components/shared/EventPicker";
/** Staff → Volunteers: the roster on the day — who's here, ticking people in, and the check-in QR code. */
export default function StaffVolunteersPage() {
@@ -48,13 +48,10 @@ export default function StaffVolunteersPage() {
{events && !events.length && <div className="p-3 border rounded bg-amber-50 text-amber-800 text-sm">No events have volunteer rosters yet. A supervisor adds serving roles under Supervisor → Volunteer rosters.</div>}
{events && events.length > 0 && (
<div className="flex flex-wrap gap-2 items-end print:hidden">
<label className="text-sm">
<span className="block text-xs text-gray-500 mb-1">Event</span>
<select className="border rounded-lg px-3 py-2 text-sm min-w-[14rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
{!eventId && <option value="">Choose an event…</option>}
{events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))}</option>)}
</select>
</label>
<div className="text-sm">
<label className="block text-xs text-gray-500 mb-1" htmlFor="staff-volunteers-event">Event</label>
<EventPicker id="staff-volunteers-event" className="w-full" events={events} value={eventId} onChange={setEventId} />
</div>
{eventId && <button onClick={() => setShowQr(q => !q)} className="px-3 py-2 rounded-lg border text-sm">{showQr ? "Show the roster" : "Show the check-in QR code"}</button>}
</div>
)}
@@ -5,12 +5,12 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { DndContext, DragEndEvent, PointerSensor, TouchSensor, useDraggable, useDroppable, useSensor, useSensors } from "@dnd-kit/core";
import { apiFetch } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { BedDouble, Plus, Wand2, Printer, Trash2, Pencil, Eraser } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
type Group = { id: string; name: string; capacity: number | null; gender: "male" | "female" | null; leaderUserId: string | null; leader?: { id: string; name: string } | null };
type AllocSet = { id: string; name: string; showOnTicket: boolean; groups: Group[]; assignments: { attendeeId: string; groupId: string }[] };
@@ -222,10 +222,8 @@ export default function AllocationsPage() {
<div className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem]" value={eventId} onChange={e => { setEventId(e.target.value); setSetId(""); }}>
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
</select>
<label className="block text-xs text-gray-600 mb-1" htmlFor="allocations-event">Event</label>
<EventPicker id="allocations-event" label="Event" events={events} value={eventId} onChange={id => { setEventId(id); setSetId(""); }} />
</div>
{board && (
<div className="flex flex-wrap items-center gap-1">
@@ -18,6 +18,8 @@ import { printTicketCards, ticketTypeLabel as ticketLabel } from "@/lib/ticketPr
import { bookableSessions, choosesSessions, isPickOption, todaysSession } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2 } from "@/lib/apiV2";
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
@@ -426,18 +428,7 @@ export default function AtTheDoorPage() {
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">At The Door</h1>
</div>
</div>
<select
className="border rounded px-3 py-2 text-sm w-full sm:w-72 max-w-full"
value={eventId}
onChange={e => setEventId(e.target.value)}
>
<option value="">Select event…</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>
{ev.title}
</option>
))}
</select>
<EventPicker className="w-full sm:w-72" events={events} value={eventId} onChange={setEventId} placeholder="Select event" />
<button
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 shrink-0 self-start sm:self-auto"
onClick={() => router.push("/dashboard")}
@@ -1094,12 +1085,13 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
const qty = qtyFor(ticket);
try {
setSubmittingId(ticket.id);
const res = await apiFetch<{ qtyRedeemed?: number; remaining?: number }>(
`/api/tickets/scan/${encodeURIComponent(ticket.qrCode)}?eventId=${encodeURIComponent(eventId)}`,
{ method: "POST", authToken: token, body: { qty } }
);
setInfo(`${res?.qtyRedeemed ?? qty} checked in for ${registration.user?.name || "guest"}${
(res?.remaining ?? 0) > 0 ? ` — ${res.remaining} remaining` : " — fully checked in"
const { data: res } = await apiV2("post", "/checkin/{qrCode}", {
params: { qrCode: ticket.qrCode }, authToken: token, body: { people: qty, eventId },
});
// Already used, consent missing, wrong session…: say why.
if (res.outcome !== "checked_in") throw new Error(res.message);
setInfo(`${res.checkedIn} checked in for ${registration.user?.name || "guest"}${
(res.remaining ?? 0) > 0 ? ` — ${res.remaining} remaining` : " — fully checked in"
}. Confirmation sent.`);
await load();
} catch (e) {
@@ -7,10 +7,10 @@ import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { errorMessage } from "@/lib/utils";
import { formatDate } from "@/lib/date";
import { fetchApiFile, saveBlob } from "@/lib/export";
import type { AttendanceRegister, AttendanceRow, Event } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
type Filter = "all" | "completed" | "not_completed" | "missed_recent";
@@ -154,13 +154,8 @@ function AttendancePageInner() {
<div className="border rounded-xl p-4 bg-white shadow-sm space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem] max-w-full" value={eventId} onChange={e => setEventId(e.target.value)}>
{events.length === 0 && <option value="">No events</option>}
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))}{ev.scheduleType === "sessions" ? ` · ${(ev.sessions || []).filter(s => !s.cancelled).length} sessions` : ""})</option>
))}
</select>
<label className="block text-xs text-gray-600 mb-1" htmlFor="attendance-event">Event</label>
<EventPicker id="attendance-event" label="Event" events={events} value={eventId} onChange={id => { setEventId(id); }} placeholder="No events" />
</div>
<div className="relative">
<Search className="w-4 h-4 text-gray-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
@@ -9,6 +9,7 @@ import { Gift, Plus, X, Trash2, Pencil, Power, CalendarClock } from "lucide-reac
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import type { BundleGroupPricing, BundleKind, EventBundle } from "@/types";
import { EventPicker, defaultEventId } from "@/components/shared/EventPicker";
type OptionLite = { id: string; name: string; price: number; variants?: { id: string; name: string; price?: number | null }[] };
type EventLite = { id: string; title: string; startDate: string; endDate?: string; eventOptions: OptionLite[] };
@@ -56,7 +57,6 @@ const fmtRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
// fieldCls has no width, for fields sized by their row (inputCls is full width).
const fieldCls = "border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
const inputCls = `w-full ${fieldCls}`;
const isPast = (ev: EventLite) => new Date(ev.endDate || ev.startDate).getTime() < Date.now();
const KIND_INFO: Record<BundleKind, { label: string; hint: string; example: string }> = {
exact: { label: "Exact set", hint: "These tickets in these numbers", example: "2 adults + 3 kids for R220" },
@@ -92,7 +92,6 @@ export default function BundlesPage() {
}, [user, loading, router]);
const [allEvents, setAllEvents] = useState<EventLite[]>([]);
const [showPast, setShowPast] = useState(false);
const [eventId, setEventId] = useState("");
const [bundles, setBundles] = useState<EventBundle[]>([]);
const [fetching, setFetching] = useState(false);
@@ -107,19 +106,12 @@ export default function BundlesPage() {
.catch(e => setError(errorMessage(e) || "Failed to load events"));
}, [token, canView, setError]);
// Upcoming events soonest first; past ones (when shown) after them, most recent first.
const events = useMemo(() => {
const upcoming = allEvents.filter(e => !isPast(e)).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
const past = allEvents.filter(isPast).sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
return showPast ? [...upcoming, ...past] : upcoming;
}, [allEvents, showPast]);
// Keep the picked event while it's in the list; otherwise start on the next upcoming one.
// Start on the next upcoming event (or the latest one).
useEffect(() => {
if (!events.some(e => e.id === eventId)) setEventId(events[0]?.id || "");
}, [events, eventId]);
if (!allEvents.some(e => e.id === eventId)) setEventId(defaultEventId(allEvents));
}, [allEvents, eventId]);
const event = events.find(e => e.id === eventId) || null;
const event = allEvents.find(e => e.id === eventId) || null;
const options = event?.eventOptions || [];
const load = useCallback(async () => {
@@ -194,17 +186,9 @@ export default function BundlesPage() {
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-end gap-3 mb-4">
<div className="w-full sm:w-auto">
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded-lg px-3 py-2 text-sm w-full sm:min-w-[20rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
{events.length === 0 && <option value="">{showPast ? "No events" : "No upcoming events"}</option>}
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title} ({fmtDate(ev.startDate)}){isPast(ev) ? " · past" : ""}</option>
))}
</select>
<label className="block text-xs text-gray-600 mb-1" htmlFor="bundles-event">Event</label>
<EventPicker id="bundles-event" className="w-full sm:min-w-[20rem]" events={allEvents} value={eventId} onChange={setEventId} placeholder="No events" />
</div>
<label className="flex items-center gap-2 text-sm text-gray-700 sm:pb-2">
<input type="checkbox" checked={showPast} onChange={e => setShowPast(e.target.checked)} /> Show past events
</label>
</div>
{event && options.length === 0 && (
@@ -11,6 +11,7 @@ import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@
import { errorMessage } from "@/lib/utils";
import type { Event, ConsentRow, MedicalRow } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker, defaultEventId } from "@/components/shared/EventPicker";
const STATUS_STYLE: Record<ConsentRow["status"], { label: string; cls: string }> = {
@@ -49,7 +50,7 @@ export default function ConsentTrackerPage() {
.then(evs => {
const youth = (evs || []).filter(e => e.requiresParentalConsent);
setEvents(youth);
if (!eventId && youth.length) setEventId(youth[youth.length - 1].id);
if (!eventId && youth.length) setEventId(defaultEventId(youth));
})
.catch(e => setError(e?.message || "Failed to load events"));
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -149,11 +150,8 @@ export default function ConsentTrackerPage() {
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-wrap items-end gap-3 mb-4">
<div>
<label className="block text-xs text-gray-600 mb-1">Youth event</label>
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
{events.length === 0 && <option value="">No youth events</option>}
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
</select>
<label className="block text-xs text-gray-600 mb-1" htmlFor="consent-event">Youth event</label>
<EventPicker id="consent-event" label="Youth event" events={events} value={eventId} onChange={id => { setEventId(id); }} placeholder="No youth events" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Show</label>
@@ -12,6 +12,7 @@ import type { Event, MessageSendResult, Registration, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { MessageKindPicker, matchedText, type MessageKind } from "@/components/shared/MessageKindPicker";
import { CampusAudiencePicker } from "@/components/shared/CampusAudiencePicker";
import { EventPicker } from "@/components/shared/EventPicker";
type Attendee = { id: string; name: string; email: string; pref: string };
type TemplateKey = 'custom'|'payment_reminder'|'event_reminder'|'tickets';
@@ -190,16 +191,8 @@ function EmailAttendeesPageInner() {
const [tab, setTab] = useState<'attendees'|'automations'|'broadcasts'|'scheduled'>('attendees');
const [allEvents, setAllEvents] = useState<Event[]>([]);
const [evIncludePast, setEvIncludePast] = useState(false);
const events = useMemo(() => {
const now = Date.now();
if (evIncludePast) return allEvents;
return allEvents.filter(ev => {
const t = new Date(ev.endDate).getTime();
return !isNaN(t) && t > now;
});
}, [allEvents, evIncludePast]);
// Every event; the pickers open on upcoming ones and keep past ones a click away.
const events = allEvents;
const loadEvents = useCallback(async () => {
try {
@@ -544,18 +537,9 @@ function EmailAttendeesPageInner() {
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<div className="flex gap-2 items-center">
<select className="border rounded px-3 py-2 text-sm flex-1 min-w-0 max-w-full" value={eventId} onChange={e => setEventId(e.target.value)}>
<option value="">Select an event…</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title}{ev.endDate && new Date(ev.endDate) < new Date() ? ' (past)' : ''}</option>
))}
</select>
<EventPicker className="w-full" events={events} value={eventId} onChange={setEventId} placeholder="Select an event" />
{loadingEvents && <span className="text-xs text-gray-500">Loading…</span>}
</div>
<label className="flex items-center gap-1.5 text-xs text-gray-500 mt-1 cursor-pointer">
<input type="checkbox" checked={evIncludePast} onChange={e => setEvIncludePast(e.target.checked)} />
Include past events
</label>
</div>
<div className="grid sm:grid-cols-3 gap-3">
@@ -740,12 +724,7 @@ function EmailAttendeesPageInner() {
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Event (optional for placeholders)</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={broadcastEventId} onChange={e => setBroadcastEventId(e.target.value)}>
<option value="">No event</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<EventPicker className="w-full" events={events} value={broadcastEventId} onChange={setBroadcastEventId} emptyLabel="No event" />
</div>
</div>
@@ -1122,12 +1101,7 @@ With gratitude`);
<div className="grid gap-4">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={autoEventId} onChange={e => setAutoEventId(e.target.value)}>
<option value="">Select an event…</option>
{(events||[]).map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<EventPicker className="w-full" events={events} value={autoEventId} onChange={setAutoEventId} placeholder="Select an event" />
</div>
<div className="grid gap-4">
@@ -1219,12 +1193,7 @@ With gratitude`);
<div className="grid sm:grid-cols-2 gap-3 mb-2">
<div>
<label className="block text-xs text-gray-600 mb-1">Promo Event</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={promoEventId} onChange={e => setPromoEventId(e.target.value)}>
<option value="">Select event to promote…</option>
{(events||[]).map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<EventPicker className="w-full" events={events} value={promoEventId} onChange={setPromoEventId} placeholder="Select event to promote" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at</label>
@@ -9,6 +9,7 @@ import { Ticket } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event, EventOption } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
// Format a Date (or date-like input) to the value expected by <input type="datetime-local">
// This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
@@ -228,12 +229,7 @@ function EventOptionsContent() {
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="text-lg font-semibold mb-3">Select event</div>
<select className="w-full border rounded px-3 py-2 text-sm" value={selectedEventId} onChange={e => setSelectedEventId(e.target.value)}>
<option value="">Select an event…</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<EventPicker className="w-full" events={events} value={selectedEventId} onChange={setSelectedEventId} placeholder="Select an event" />
{selectedEventId && (
<div className="mt-3 text-xs text-gray-600">
{(() => {
@@ -10,6 +10,7 @@ import { FileText } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event, EventForm, FormResponse, Registration, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph' | 'rating' | 'multiple_choice';
@@ -493,19 +494,8 @@ export default function FormsBrowserPage() {
<div className="text-base font-semibold mb-3">Filters</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-3 items-end">
<div className="lg:col-span-2">
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select
className="border rounded px-2 py-1.5 text-sm w-full"
value={selectedEventId}
onChange={e => setSelectedEventId(e.target.value)}
>
<option value="">All events</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>
{ev.title}{!ev.isActive ? ' (inactive)' : ''}{ev.endDate && new Date(ev.endDate) < new Date() ? ' (past)' : ''}
</option>
))}
</select>
<label className="block text-xs text-gray-600 mb-1" htmlFor="forms-view-event">Event</label>
<EventPicker id="forms-view-event" className="w-full" events={events} value={selectedEventId} onChange={setSelectedEventId} emptyLabel="All events" />
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500">
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={evIncludePast} onChange={e => setEvIncludePast(e.target.checked)} /> Include past events
@@ -629,18 +619,9 @@ export default function FormsBrowserPage() {
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-base font-semibold mb-3">Edit form structure</div>
<div className="mb-4">
<label className="block text-xs text-gray-600 mb-1">Select event</label>
<select
className="border rounded px-2 py-1.5 text-sm w-full sm:max-w-sm"
value={selectedEventId}
onChange={e => { setSelectedEventId(e.target.value); setFormLoadedFor(""); }}
>
<option value="">Choose an event…</option>
{allEvents.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title}{!ev.isActive ? ' (inactive)' : ''}</option>
))}
</select>
<div className="text-xs text-gray-400 mt-1">All events shown (including inactive/past) so you can edit historical forms.</div>
<label className="block text-xs text-gray-600 mb-1" htmlFor="forms-manage-event">Select event</label>
<EventPicker id="forms-manage-event" className="w-full sm:max-w-sm" events={allEvents} value={selectedEventId} onChange={id => { setSelectedEventId(id); setFormLoadedFor(""); }} />
<div className="text-xs text-gray-500 mt-1">Inactive and past events are included (search, or Show past events), so you can edit historical forms.</div>
{formLoading && <span className="text-xs text-gray-400 mt-1 block">Loading form…</span>}
</div>
{!selectedEventId ? (
@@ -678,10 +659,7 @@ export default function FormsBrowserPage() {
<div className="grid sm:grid-cols-2 gap-3 mb-4 items-end">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded px-2 py-1.5 text-sm w-full" value={fillEventId} onChange={e => setFillEventId(e.target.value)}>
<option value="">Select event…</option>
{allEvents.map(ev => (<option key={ev.id} value={ev.id}>{ev.title}</option>))}
</select>
<EventPicker className="w-full" events={allEvents} value={fillEventId} onChange={setFillEventId} placeholder="Select event" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Registration</label>
@@ -5,10 +5,10 @@ import Link from "next/link";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Baby, Plus, Trash2 } from "lucide-react";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
type Room = { id: string; name: string; capacity: number | null; minAge: number | null; maxAge: number | null; ratio: number | null; order: number };
type Settings = {
@@ -96,13 +96,10 @@ export default function KidsSetupPage() {
</div>
</div>
<label className="text-sm block max-w-md">
<span className="block text-xs text-gray-500 mb-1">Event</span>
<select className="w-full border rounded-lg px-3 py-2 text-sm" value={eventId} onChange={e => setEventId(e.target.value)}>
<option value="">Choose an event…</option>
{events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))}{e.kidsCheckIn ? " · kids check-in on" : ""}</option>)}
</select>
</label>
<div className="text-sm block max-w-md">
<label className="block text-xs text-gray-500 mb-1" htmlFor="kids-setup-event">Event</label>
<EventPicker id="kids-setup-event" className="w-full" events={events} value={eventId} onChange={setEventId} describe={e => (e.kidsCheckIn ? "kids check-in on" : null)} />
</div>
{msg && <div role="status" className={`mt-3 p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
@@ -16,6 +16,7 @@ import { choosesSessions, isPickOption } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
import type { Event, EventOption, PublicBundle, Registration, RegistrationOption, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
// ─── Pricing helpers ─────────────────────────────────────────────────────────
@@ -430,12 +431,7 @@ export default function ManualRegistrationPage() {
<div className="grid md:grid-cols-2 gap-6">
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">1) Choose event</div>
<select className="w-full border rounded px-3 py-2 text-sm" value={selectedEventId} onChange={e => setSelectedEventId(e.target.value)}>
<option value="">Select an event…</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<EventPicker className="w-full" events={events} value={selectedEventId} onChange={setSelectedEventId} placeholder="Select an event" />
{selectedEventId && (
<div className="mt-3 text-xs text-gray-600">
{(() => {
@@ -4,12 +4,12 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { WifiOff, Unlock, CheckCircle2 } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
type Lock = { id: string; sectionName: string | null; deviceName: string | null; user: { name: string }; lockedAt: string; snapshotAt: string | null; lastSyncAt: string | null };
type Report = {
@@ -101,10 +101,8 @@ export default function OfflineScansPage() {
<div className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
</select>
<label className="block text-xs text-gray-600 mb-1" htmlFor="offline-scans-event">Event</label>
<EventPicker id="offline-scans-event" label="Event" events={events} value={eventId} onChange={id => { setEventId(id); }} />
</div>
<label className="flex items-center gap-2 text-sm text-gray-700 pb-2">
<input type="checkbox" checked={onlyOpen} onChange={e => setOnlyOpen(e.target.checked)} /> Only reports that need review
@@ -13,6 +13,7 @@ import type { Event, Payment, Registration, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { WaitingForPayment } from "@/components/payments/WaitingForPayment";
import { EventPicker } from "@/components/shared/EventPicker";
// A donation is never mutated once created — assigning it to a registration creates a separate
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
@@ -367,22 +368,20 @@ function PaymentsContent() {
if (uid) setSelectedUserId(String(uid));
}, [registrationId, registrations]);
const [eventsIncludePast, setEventsIncludePast] = useState(false);
const [eventsIncludeInactive, setEventsIncludeInactive] = useState(false);
useEffect(() => {
(async () => {
try {
if (!token) return;
const params = new URLSearchParams();
if (eventsIncludePast) params.set("includePast", "true");
// Past events too: the pickers open on upcoming ones and keep past ones a click away.
const params = new URLSearchParams({ includePast: "true" });
if (eventsIncludeInactive) params.set("includeInactive", "true");
const qs = params.toString() ? `?${params.toString()}` : "";
const evs = await apiFetch<Event[]>(`/api/events/all${qs}`, { authToken: token });
const evs = await apiFetch<Event[]>(`/api/events/all?${params.toString()}`, { authToken: token });
setEvents(evs || []);
} catch {}
})();
}, [token, eventsIncludePast, eventsIncludeInactive]);
}, [token, eventsIncludeInactive]);
useEffect(() => {
// Keep inputs sensible when toggling donation
@@ -665,10 +664,7 @@ function PaymentsContent() {
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={reconcileForm.eventId} onChange={e => setReconcileForm(prev => ({ ...prev, eventId: e.target.value }))}>
<option value="">Select event…</option>
{events.map(ev => (<option key={ev.id} value={ev.id}>{ev.title}</option>))}
</select>
<EventPicker className="w-full" events={events} value={reconcileForm.eventId} onChange={id => setReconcileForm(prev => ({ ...prev, eventId: id }))} placeholder="Select event" />
</div>
<div className="flex gap-2">
<button className="px-3 py-1.5 rounded bg-emerald-600 text-white text-sm disabled:opacity-50" disabled={!reconcileForm.eventId || reconcileForm.submitting} onClick={submitReconcile}>{reconcileForm.submitting ? 'Saving…' : 'Confirm'}</button>
@@ -722,15 +718,8 @@ function PaymentsContent() {
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Event (for donations)</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={eventId} onChange={e => setEventId(e.target.value)} disabled={!isDonation}>
<option value="">Select event…</option>
{events.map(ev => (<option key={ev.id} value={ev.id}>{ev.title}</option>))}
</select>
<EventPicker className="w-full" events={events} value={eventId} onChange={setEventId} placeholder="Select event" disabled={!isDonation} />
<div className="flex items-center gap-4 mt-1.5 text-xs text-gray-500">
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={eventsIncludePast} onChange={e => setEventsIncludePast(e.target.checked)} />
Include past events
</label>
{user?.role === "admin" && (
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={eventsIncludeInactive} onChange={e => setEventsIncludeInactive(e.target.checked)} />
@@ -4,13 +4,13 @@ import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { BackLink } from "@/components/shared/BackLink";
import { RosterSheet } from "@/components/volunteers/RosterSheet";
import { CheckInQr } from "@/components/volunteers/CheckInQr";
import { HandHeart } from "lucide-react";
import { hoursText, type HoursRow, type RosterEvent } from "@/lib/volunteers";
import { EventPicker } from "@/components/shared/EventPicker";
type Settings = {
event: { id: string; title: string; volunteerOptionId: string | null };
@@ -91,13 +91,10 @@ export default function VolunteerRostersPage() {
</div>
</div>
<label className="text-sm block max-w-md print:hidden">
<span className="block text-xs text-gray-500 mb-1">Event</span>
<select className="w-full border rounded-lg px-3 py-2 text-sm" value={eventId} onChange={e => setEventId(e.target.value)}>
<option value="">Choose an event…</option>
{events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))}{e.roles ? ` · ${e.roles} role${e.roles === 1 ? "" : "s"}` : ""}</option>)}
</select>
</label>
<div className="text-sm block max-w-md print:hidden">
<label className="block text-xs text-gray-500 mb-1" htmlFor="volunteers-event">Event</label>
<EventPicker id="volunteers-event" className="w-full" events={events} value={eventId} onChange={setEventId} describe={e => (e.roles ? `${e.roles} role${e.roles === 1 ? "" : "s"}` : null)} />
</div>
{msg && <div role="status" className={`mt-3 p-3 rounded-lg text-sm print:hidden ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
@@ -12,6 +12,7 @@ import { errorMessage } from "@/lib/utils";
import { entryLineName } from "@/lib/waitlist";
import type { Event } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
type Entry = {
id: string;
@@ -117,11 +118,8 @@ export default function WaitlistPage() {
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-wrap items-end gap-3 mb-4">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
{events.length === 0 && <option value="">No events with a waitlist</option>}
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
</select>
<label className="block text-xs text-gray-600 mb-1" htmlFor="waitlist-event">Event</label>
<EventPicker id="waitlist-event" label="Event" events={events} value={eventId} onChange={id => { setEventId(id); }} placeholder="No events with a waitlist" />
</div>
{selected && (
<p className="text-xs text-gray-500">
@@ -12,6 +12,7 @@ import type { Event, MessageSendResult, Registration, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { MessageKindPicker, matchedText, type MessageKind } from "@/components/shared/MessageKindPicker";
import { CampusAudiencePicker } from "@/components/shared/CampusAudiencePicker";
import { EventPicker } from "@/components/shared/EventPicker";
// Attendee with preference info
type Attendee = { id: string; name: string; phone: string; pref: string };
@@ -263,12 +264,7 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: Event[
<div className="grid gap-4">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={autoEventId} onChange={(e) => setAutoEventId(e.target.value)}>
<option value="">Select an event…</option>
{(events || []).map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<EventPicker className="w-full" events={events} value={autoEventId} onChange={setAutoEventId} placeholder="Select an event" />
</div>
<div className="grid gap-4">
@@ -328,12 +324,7 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: Event[
<div className="grid sm:grid-cols-2 gap-3 mb-2">
<div>
<label className="block text-xs text-gray-600 mb-1">Promo Event</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={promoEventId} onChange={(e) => setPromoEventId(e.target.value)}>
<option value="">Select event to promote…</option>
{(events || []).map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<EventPicker className="w-full" events={events} value={promoEventId} onChange={setPromoEventId} placeholder="Select event to promote" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at</label>
@@ -379,13 +370,8 @@ function WhatsAppAttendeesPageInner() {
const [tab, setTab] = useState<"attendees" | "automations" | "broadcasts" | "scheduled">("attendees");
const [loadingEvents, setLoadingEvents] = useState(false);
const [allEvents, setAllEvents] = useState<Event[]>([]);
const [evIncludePast, setEvIncludePast] = useState(false);
const events = useMemo(() => {
const now = Date.now();
if (evIncludePast) return allEvents;
return allEvents.filter((ev) => !isNaN(new Date(ev.endDate).getTime()) && new Date(ev.endDate).getTime() > now);
}, [allEvents, evIncludePast]);
// Every event; the pickers open on upcoming ones and keep past ones a click away.
const events = allEvents;
useEffect(() => {
const loadEvents = async () => {
@@ -651,18 +637,9 @@ function WhatsAppAttendeesPageInner() {
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<div className="flex gap-2 items-center">
<select className="border rounded px-3 py-2 text-sm flex-1 min-w-0" value={eventId} onChange={(e) => setEventId(e.target.value)}>
<option value="">Select an event…</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}{ev.endDate && new Date(ev.endDate) < new Date() ? " (past)" : ""}</option>
))}
</select>
<EventPicker className="w-full" events={events} value={eventId} onChange={setEventId} placeholder="Select an event" />
{loadingEvents && <span className="text-xs text-gray-500">Loading…</span>}
</div>
<label className="flex items-center gap-1.5 text-xs text-gray-500 mt-1 cursor-pointer">
<input type="checkbox" checked={evIncludePast} onChange={(e) => setEvIncludePast(e.target.checked)} />
Include past events
</label>
</div>
<div className="grid sm:grid-cols-3 gap-3">
@@ -814,12 +791,7 @@ function WhatsAppAttendeesPageInner() {
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Event (optional for placeholders)</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={broadcastEventId} onChange={(e) => setBroadcastEventId(e.target.value)}>
<option value="">No event</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<EventPicker className="w-full" events={events} value={broadcastEventId} onChange={setBroadcastEventId} emptyLabel="No event" />
</div>
</div>
+2 -4
View File
@@ -2,6 +2,7 @@
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, createFullPaymentCheckout } from "@/lib/api";
import { apiV2 } from "@/lib/apiV2";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/date";
@@ -454,10 +455,7 @@ export default function UserDashboardPage() {
if (!token || !activeRegId) return;
setCancelLoading(true);
try {
await apiFetch(`/api/registrations/${encodeURIComponent(activeRegId)}`, {
method: 'DELETE',
authToken: token,
});
await apiV2("post", "/registrations/{id}/cancel", { params: { id: activeRegId }, authToken: token });
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
setRegistrations(myRegs);
setActiveRegId(null);
@@ -12,6 +12,7 @@ import { User, Lock, ShieldAlert, ShieldCheck, ShieldOff, Trash2, Clock, LogIn,
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { useCampuses } from "@/hooks/useCampuses";
import { apiV2 } from "@/lib/apiV2";
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset" | "two_factor_enabled" | "two_factor_disabled"; device: string | null; createdAt: string };
@@ -61,19 +62,18 @@ export default function UserProfilePage() {
setSavingProfile(true);
setProfileMsg(null);
try {
const res = await apiFetch<{ token?: string }>("/api/users/profile", {
method: "PUT",
const res = await apiV2("patch", "/me", {
authToken: token,
body: {
name,
email,
phoneNumber: phone || null,
phone: phone || null,
notificationPreference: hasValidPhone ? notifPref : "email",
...(campusInfo.enabled ? { homeCampusId: homeCampusId || null } : {}),
},
});
// Backend returns a refreshed token — save it so the session stays valid
if (res?.token) updateToken(res.token);
// A refreshed token comes back — save it so the session stays valid
updateToken(res.token);
setProfileMsg({ type: "ok", text: "Profile updated." });
} catch (e) {
setProfileMsg({ type: "err", text: errorMessage(e) || "Failed to update profile." });
@@ -103,12 +103,8 @@ export default function UserProfilePage() {
setSavingPw(true);
setPwMsg(null);
try {
const res = await apiFetch<{ token?: string }>("/api/users/profile", {
method: "PUT",
authToken: token,
body: { currentPassword, password: newPassword },
});
if (res?.token) updateToken(res.token);
const res = await apiV2("patch", "/me", { authToken: token, body: { currentPassword, password: newPassword } });
updateToken(res.token);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
@@ -1,12 +1,12 @@
"use client";
import React, { useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter } from "next/navigation";
import { KeyRound } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { apiV2 } from "@/lib/apiV2";
export default function ResetPasswordPage() {
const { token } = useAuth();
@@ -31,7 +31,7 @@ export default function ResetPasswordPage() {
if (!canSubmit) return;
try {
setLoading(true);
await apiFetch("/api/users/profile", { method: "PUT", authToken: token, body: { password, currentPassword: current } });
await apiV2("patch", "/me", { authToken: token, body: { password, currentPassword: current } });
setStatus("Your password has been updated successfully.");
setCurrent("");
setPassword("");
@@ -3,6 +3,7 @@
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { apiV2 } from "@/lib/apiV2";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
@@ -552,6 +553,15 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
setError(`The minimum donation is R${MIN_DONATION}. Choose None to skip it.`);
return;
}
// Registers through API v2; returns the new registration (balance = what's owed now).
const register = async (extra: { couponCode?: string; waitlistToken?: string; guest?: { name: string; email: string; phone?: string } }, authToken?: string | null) => {
const named = namedAttendees();
const { data } = await apiV2("post", "/registrations", {
body: { eventId, options: items, analyticsSessionId: analyticsSessionId(), ...(named.length > 0 ? { attendees: named, ticketDelivery: delivery } : {}), ...extra },
authToken,
});
return data;
};
const donationParam = donation > 0 ? `&donation=${encodeURIComponent(donation.toFixed(2))}` : "";
// Claim link without logging in: the link identifies the person.
@@ -559,11 +569,8 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
try {
setError(null);
setCreatingRegistration(true);
const res = await apiFetch<{ id: string; totalDueComputed?: number }>("/api/registrations", {
method: "POST",
body: { eventId, options: items, waitlistToken, analyticsSessionId: analyticsSessionId(), ...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}) },
});
if (res?.id) window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((res.totalDueComputed ?? payable).toFixed(2))}`;
const res = await register({ waitlistToken });
window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent(Math.max(0, res.balance).toFixed(2))}`;
} catch (e) {
setError(errorMessage(e) || "Registration failed");
} finally {
@@ -581,21 +588,10 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
try {
setError(null);
setCreatingRegistration(true);
const res = await apiFetch<{ id: string; totalDueComputed?: number; couponError?: string }>("/api/registrations", {
method: "POST",
body: {
eventId,
options: items,
...(coupon ? { couponCode: coupon.code } : {}),
analyticsSessionId: analyticsSessionId(),
...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}),
...(waitlistToken ? { waitlistToken } : {}),
},
authToken: token,
});
if (res?.id) {
const res = await register({ ...(coupon ? { couponCode: coupon.code } : {}), ...(waitlistToken ? { waitlistToken } : {}) }, token);
{
const hasForm = !!(event?.form && Array.isArray(event.form.fields) && event.form.fields.length > 0);
const due = res.totalDueComputed ?? payable;
const due = Math.max(0, res.balance);
const next = hasForm
? `/forms?registrationId=${encodeURIComponent(res.id)}${donationParam}`
: `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((due || 0).toFixed(2))}${donationParam}`;
@@ -628,15 +624,8 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
try {
setError(null);
setCreatingRegistration(true);
const res = await apiFetch<{ id: string }>("/api/registrations", {
method: "POST",
body: {
eventId, options: items, guestName: guestName.trim(), guestEmail: guestEmail.trim(), guestPhone: guestPhone.trim() || undefined,
analyticsSessionId: analyticsSessionId(),
...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}),
},
});
if (res?.id) {
const res = await register({ guest: { name: guestName.trim(), email: guestEmail.trim(), ...(guestPhone.trim() ? { phone: guestPhone.trim() } : {}) } });
{
const hasForm = !!(event?.form && Array.isArray(event.form.fields) && event.form.fields.length > 0);
if (hasForm) {
window.location.href = `/forms?registrationId=${encodeURIComponent(res.id)}`;
+2 -13
View File
@@ -12,6 +12,7 @@ import type { Event, PublicBundle, Registration } from "@/types";
import { choosesSessions, isPickOption } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
import { ModalOverlay } from "@/components/shared/ModalOverlay";
import { EventPicker } from "@/components/shared/EventPicker";
// ─── Types ────────────────────────────────────────────────────────────────────
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
@@ -841,19 +842,7 @@ export default function SelfServicePage() {
) : (
<div>
<label htmlFor="kiosk-event" className="block text-sm font-medium text-gray-700 mb-1">Event</label>
<select
id="kiosk-event"
value={selectedEventId}
onChange={(e) => setSelectedEventId(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="">— Select an event —</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>
{ev.title} ({fmtDate(ev.startDate)})
</option>
))}
</select>
<EventPicker id="kiosk-event" className="w-full text-base" events={events} value={selectedEventId} onChange={setSelectedEventId} placeholder="Select an event" />
</div>
)}
{selectedEvent && (
@@ -0,0 +1,172 @@
"use client";
import React, { useState } from "react";
import { BookOpen, ChevronDown, ChevronRight, Copy } from "lucide-react";
type EventType = { type: string; label: string; description: string; example?: unknown };
const RETRIES = "1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours";
const VERIFY_NODE = `// Node.js / Express — check the signature before trusting a request.
const crypto = require("crypto");
const SECRET = process.env.CROSSEVENTS_WEBHOOK_SECRET; // whsec_…
app.post("/crossevents-hook", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-CrossEvents-Signature") || ""; // t=1767225600,v1=5f0c…
const { t, v1 } = Object.fromEntries(header.split(",").map(p => p.split("=")));
const body = req.body.toString("utf8"); // the raw body, unparsed
const expected = crypto.createHmac("sha256", SECRET).update(\`\${t}.\${body}\`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300; // within 5 minutes
if (!fresh || !v1 || !crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) {
return res.status(400).send("bad signature");
}
const event = JSON.parse(body);
// Deliveries can repeat: skip an event.id you've already handled.
res.sendStatus(200); // answer quickly, then do the slow work
handle(event);
});`;
const VERIFY_PHP = `<?php // PHP — check the signature before trusting a request.
$secret = getenv('CROSSEVENTS_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_CROSSEVENTS_SIGNATURE'] ?? ''), $sig);
$expected = hash_hmac('sha256', $sig['t'] . '.' . $body, $secret);
if (abs(time() - (int) $sig['t']) > 300 || !hash_equals($expected, $sig['v1'] ?? '')) {
http_response_code(400); exit('bad signature');
}
$event = json_decode($body, true);
http_response_code(200);`;
function CopyButton({ text, label = "Copy" }: { text: string; label?: string }) {
const [done, setDone] = useState(false);
return (
<button type="button" className="inline-flex items-center gap-1 px-2 py-0.5 rounded border bg-white text-[11px] hover:bg-gray-50"
onClick={() => { navigator.clipboard?.writeText(text).then(() => { setDone(true); setTimeout(() => setDone(false), 1500); }).catch(() => {}); }}>
<Copy className="w-3 h-3" aria-hidden /> {done ? "Copied" : label}
</button>
);
}
function Code({ text }: { text: string }) {
return (
<div className="relative">
<div className="absolute right-2 top-2"><CopyButton text={text} /></div>
<pre className="max-h-80 overflow-auto rounded-lg bg-gray-900 text-gray-100 p-3 pr-20 text-[11px] leading-relaxed">{text}</pre>
</div>
);
}
const H = ({ children }: { children: React.ReactNode }) => <h4 className="text-sm font-semibold text-gray-900 mt-4 mb-1.5">{children}</h4>;
/**
* Site settings → Webhooks → "How webhooks work": what arrives, when, how to check it's from us,
* and an example of every event type, for whoever builds the receiving side.
*/
export function WebhookGuide({ eventTypes, keepDays, defaultOpen = false }: { eventTypes: EventType[]; keepDays: number; defaultOpen?: boolean }) {
const [open, setOpen] = useState(defaultOpen);
const [openType, setOpenType] = useState<string | null>(null);
const [lang, setLang] = useState<"node" | "php">("node");
const envelope = (t: EventType) => JSON.stringify({ id: "evt_3f9c2a1e-6d4b-4c8e-9a51-2b7d0e6f1c34", type: t.type, createdAt: "2026-10-29T08:16:01.512Z", data: t.example ?? {} }, null, 2);
return (
<section className="border rounded-xl bg-white" data-testid="webhook-guide">
<button type="button" aria-expanded={open} onClick={() => setOpen(o => !o)} className="w-full flex items-center gap-2 px-4 py-3 text-left">
{open ? <ChevronDown className="w-4 h-4 text-gray-500" aria-hidden /> : <ChevronRight className="w-4 h-4 text-gray-500" aria-hidden />}
<BookOpen className="w-4 h-4 text-brand-700" aria-hidden />
<span className="font-medium text-gray-900">How webhooks work</span>
<span className="text-xs text-gray-500">what is sent, the signature, and an example of each event</span>
</button>
{open && (
<div className="px-4 pb-4 text-sm text-gray-700 border-t">
<H>What happens</H>
<ol className="list-decimal pl-5 space-y-1">
<li>Something happens here, such as a registration or a payment, that one of your webhooks is set to send.</li>
<li>A second or two later, an <code>HTTP POST</code> with a JSON body goes to that webhook&apos;s address. Everything is sent from the server; nothing comes from people&apos;s browsers.</li>
<li>Your side answers with any <code>2xx</code> status within 10 seconds, and the delivery is done.</li>
<li>
Anything else counts as a failure: another status, no answer, or a redirect (redirects aren&apos;t followed). The delivery is tried again after {RETRIES}, then marked <em>failed</em>.
You can send any delivery again from its <em>Delivery log</em>.
</li>
</ol>
<H>Using it without writing code</H>
<p>
In Zapier, use <strong>Webhooks by Zapier → Catch Hook</strong>; in Make, use <strong>Webhooks → Custom webhook</strong>. Paste the address it gives you here, choose the events, then click <em>Send test</em>. The fields in the examples below become available to map into ChurchSuite, Planning Center, a spreadsheet, Mailchimp and so on.
</p>
<H>What arrives</H>
<p className="mb-2">Every request has the same envelope. <code>data</code> depends on the type (see the examples below).</p>
<div className="overflow-x-auto">
<table className="min-w-full text-xs border rounded">
<thead className="bg-gray-50 text-left"><tr><th className="px-2 py-1.5">Field</th><th className="px-2 py-1.5">Meaning</th></tr></thead>
<tbody className="divide-y">
<tr><td className="px-2 py-1.5"><code>id</code></td><td className="px-2 py-1.5">The event&apos;s id (<code>evt_…</code>). It stays the same on retries, so use it to skip events you&apos;ve already handled.</td></tr>
<tr><td className="px-2 py-1.5"><code>type</code></td><td className="px-2 py-1.5">The event type, e.g. <code>registration.paid</code>. The <em>Send test</em> button sends <code>webhook.test</code>.</td></tr>
<tr><td className="px-2 py-1.5"><code>createdAt</code></td><td className="px-2 py-1.5">When it happened (ISO 8601, UTC).</td></tr>
<tr><td className="px-2 py-1.5"><code>data</code></td><td className="px-2 py-1.5">The details. Amounts are in rand; dates are ISO 8601 in UTC.</td></tr>
</tbody>
</table>
</div>
<p className="mt-3 mb-2">Headers:</p>
<div className="overflow-x-auto">
<table className="min-w-full text-xs border rounded">
<tbody className="divide-y">
<tr><td className="px-2 py-1.5 whitespace-nowrap"><code>Content-Type</code></td><td className="px-2 py-1.5"><code>application/json</code></td></tr>
<tr><td className="px-2 py-1.5 whitespace-nowrap"><code>User-Agent</code></td><td className="px-2 py-1.5"><code>CrossEvents-Webhooks/1</code></td></tr>
<tr><td className="px-2 py-1.5 whitespace-nowrap"><code>X-CrossEvents-Event</code></td><td className="px-2 py-1.5">The event type, the same as <code>type</code>.</td></tr>
<tr><td className="px-2 py-1.5 whitespace-nowrap"><code>X-CrossEvents-Delivery</code></td><td className="px-2 py-1.5">This delivery&apos;s id. Each webhook gets its own, and it&apos;s the same on retries.</td></tr>
<tr><td className="px-2 py-1.5 whitespace-nowrap"><code>X-CrossEvents-Signature</code></td><td className="px-2 py-1.5"><code>t=&lt;unix seconds&gt;,v1=&lt;signature&gt;</code>, explained below.</td></tr>
</tbody>
</table>
</div>
<H>Checking it came from here</H>
<p className="mb-2">
Anyone who knows the address could post to it, so check the signature before acting on a request. <code>v1</code> is the HMAC-SHA256, in hex, of <code>t</code>, a full stop, and the raw request body (<code>&lt;t&gt;.&lt;body&gt;</code>). It&apos;s keyed with the webhook&apos;s signing secret, the <code>whsec_…</code> shown once when you add the webhook or make a new secret.
Work it out on the raw body before parsing the JSON, compare in constant time, and reject a <code>t</code> more than 5 minutes old.
</p>
<div className="flex gap-1 mb-2" role="group" aria-label="Example language">
{(["node", "php"] as const).map(l => (
<button key={l} type="button" aria-pressed={lang === l} onClick={() => setLang(l)}
className={`px-2 py-0.5 rounded text-xs border ${lang === l ? "bg-brand-600 text-white border-brand-600" : "bg-white hover:bg-gray-50"}`}>
{l === "node" ? "Node.js" : "PHP"}
</button>
))}
</div>
<Code text={lang === "node" ? VERIFY_NODE : VERIFY_PHP} />
<H>Good to know</H>
<ul className="list-disc pl-5 space-y-1">
<li>An event can arrive more than once, for example after a retry or a manual resend, so use <code>id</code> to skip ones you&apos;ve already handled.</li>
<li>Events can arrive out of order. Use <code>createdAt</code> and the record&apos;s <code>status</code>, not the order they arrive in.</li>
<li>Each payload is built about a second after the event, from the record as it is then, and retries send that same payload. If a registration is paid and then changed back within that second, nothing is sent.</li>
<li>Kids check-in and volunteer tickets are never sent. A registration made only of those sends nothing, and those lines are left out of <code>items</code>.</li>
<li>Only <code>https</code> addresses on the public internet are allowed. Each webhook&apos;s log keeps what was sent for {keepDays} days.</li>
<li>Turning a webhook off stops new deliveries to it. Deleting it removes its log too.</li>
</ul>
<H>Event types and examples</H>
<ul className="border rounded-lg divide-y">
{eventTypes.map(t => (
<li key={t.type}>
<button type="button" aria-expanded={openType === t.type} onClick={() => setOpenType(openType === t.type ? null : t.type)}
className="w-full flex items-start gap-2 px-3 py-2 text-left hover:bg-gray-50">
{openType === t.type ? <ChevronDown className="w-4 h-4 mt-0.5 text-gray-500 shrink-0" aria-hidden /> : <ChevronRight className="w-4 h-4 mt-0.5 text-gray-500 shrink-0" aria-hidden />}
<span className="flex-1">
<code className="text-xs font-semibold text-gray-900">{t.type}</code> <span className="text-gray-900">· {t.label}</span>
<span className="block text-xs text-gray-500">{t.description}</span>
</span>
</button>
{openType === t.type && <div className="px-3 pb-3" data-testid={`webhook-example-${t.type}`}><Code text={envelope(t)} /></div>}
</li>
))}
<li className="px-3 py-2 text-xs text-gray-600">
<code className="font-semibold text-gray-900">webhook.test</code> · Sent by <em>Send test</em>. <code>data</code> is <code>{`{ "message": "…" }`}</code>, so your side can answer 2xx and ignore it.
</li>
</ul>
</div>
)}
</section>
);
}
@@ -5,8 +5,9 @@ import { Webhook, Send, RefreshCw, KeyRound, Trash2, Pencil, Copy, ChevronDown,
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { WebhookGuide } from "./WebhookGuide";
type EventType = { type: string; label: string; description: string };
type EventType = { type: string; label: string; description: string; example?: unknown };
type Endpoint = {
id: string; name: string; url: string; events: string[]; isActive: boolean;
lastSuccessAt: string | null; lastFailureAt: string | null; createdAt: string;
@@ -188,7 +189,7 @@ export function WebhooksTab({ active }: { active: boolean }) {
const toggleEvent = (type: string) => setDraft(d => d && ({ ...d, events: d.events.includes(type) ? d.events.filter(t => t !== type) : [...d.events, type] }));
if (!data) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
if (!data) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-500">Loading…</p>;
const label = (type: string) => data.eventTypes.find(t => t.type === type)?.label || type;
return (
@@ -207,6 +208,8 @@ export function WebhooksTab({ active }: { active: boolean }) {
Webhooks send personal information (names, email addresses, phone numbers) to the service you choose, so each one is added to your operator register. Make sure you have an agreement with that service. What was sent is kept {data.keepDays} days for the delivery log. Children&apos;s details from kids check-in are never sent.
</p>
<WebhookGuide eventTypes={data.eventTypes} keepDays={data.keepDays} defaultOpen={data.endpoints.length === 0} />
{msg && <div className={`p-3 border rounded text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800" : "bg-red-50 text-red-700"}`} role="status">{msg.text}</div>}
{secret && <SecretBox secret={secret.value} onClose={() => setSecret(null)} />}
@@ -1,14 +1,18 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { ChevronDown } from "lucide-react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, Search } from "lucide-react";
// The reports sidebar's events multi-select. With many events, the search box narrows the list
// and "Select all" picks only what matches.
export default function EventsDropdown({ options, value, onChange }: {
options: { value: string; label: string }[];
// `hint`: shown beside the title (the date) and searched too.
options: { value: string; label: string; hint?: string }[];
value: string[];
onChange: (v: string[]) => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -19,9 +23,23 @@ export default function EventsDropdown({ options, value, onChange }: {
return () => document.removeEventListener("mousedown", onClick);
}, []);
const shown = useMemo(() => {
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (!words.length) return options;
return options.filter(o => {
const hay = `${o.label} ${o.hint || ""}`.toLowerCase();
return words.every(w => hay.includes(w));
});
}, [options, query]);
const toggle = (v: string) => {
onChange(value.includes(v) ? value.filter(x => x !== v) : [...value, v]);
};
const selectShown = () => onChange(Array.from(new Set([...value, ...shown.map(o => o.value)])));
const clearShown = () => {
const drop = new Set(shown.map(o => o.value));
onChange(query.trim() ? value.filter(v => !drop.has(v)) : []);
};
const summary = value.length === 0
? "No events selected"
@@ -35,25 +53,43 @@ export default function EventsDropdown({ options, value, onChange }: {
<div className="relative" ref={ref}>
<button
type="button"
aria-expanded={open}
aria-label={`Events: ${summary}`}
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between gap-2 border rounded-lg px-3 py-2 text-sm bg-white hover:bg-gray-50"
>
<span className="truncate text-left">{summary}</span>
<ChevronDown className={"w-4 h-4 text-gray-400 shrink-0 transition-transform " + (open ? "rotate-180" : "")} />
<ChevronDown className={"w-4 h-4 text-gray-500 shrink-0 transition-transform " + (open ? "rotate-180" : "")} aria-hidden />
</button>
{open && (
<div className="absolute z-20 mt-1 left-0 right-0 bg-white border rounded-lg shadow-lg max-h-64 overflow-auto p-1">
<div className="flex items-center justify-between px-2 py-1.5 text-xs text-gray-500 border-b mb-1">
<button type="button" className="hover:underline" onClick={() => onChange(options.map(o => o.value))}>Select all</button>
<button type="button" className="hover:underline" onClick={() => onChange([])}>Clear</button>
<div className="absolute z-20 mt-1 left-0 right-0 min-w-[16rem] bg-white border rounded-lg shadow-lg p-1">
<div className="flex items-center gap-2 px-2 border-b">
<Search className="w-3.5 h-3.5 text-gray-500 shrink-0" aria-hidden />
<input
autoFocus
type="text"
aria-label="Search events"
placeholder="Search events…"
className="flex-1 py-1.5 text-sm outline-none bg-transparent"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => { if (e.key === "Escape") setOpen(false); }}
/>
</div>
<div className="flex items-center justify-between px-2 py-1.5 text-xs text-gray-600 border-b mb-1">
<button type="button" className="hover:underline" onClick={selectShown}>{query.trim() ? `Select these ${shown.length}` : "Select all"}</button>
<button type="button" className="hover:underline" onClick={clearShown}>{query.trim() ? "Clear these" : "Clear"}</button>
</div>
<div className="max-h-64 overflow-auto">
{shown.map(opt => (
<label key={opt.value} className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={value.includes(opt.value)} onChange={() => toggle(opt.value)} />
<span className="truncate flex-1">{opt.label}</span>
{opt.hint && <span className="shrink-0 text-xs text-gray-500">{opt.hint}</span>}
</label>
))}
{shown.length === 0 && <div className="px-2 py-1.5 text-xs text-gray-500">{options.length ? "No events match." : "No events available."}</div>}
</div>
{options.map(opt => (
<label key={opt.value} className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={value.includes(opt.value)} onChange={() => toggle(opt.value)} />
<span className="truncate">{opt.label}</span>
</label>
))}
{options.length === 0 && <div className="px-2 py-1.5 text-xs text-gray-400">No events available.</div>}
</div>
)}
</div>
@@ -6,7 +6,7 @@ import { BackLink } from "@/components/shared/BackLink";
import { REPORTS, REPORT_CATEGORIES, type ReportKey, type ReportCategory } from "./ReportCatalog";
import EventsDropdown from "./EventsDropdown";
type EventLite = { id: string; title: string };
type EventLite = { id: string; title: string; startDate?: string };
type DatePreset = "all_time" | "this_month" | "last_month" | "this_year" | "custom";
export default function ReportsShell({
@@ -93,7 +93,7 @@ export default function ReportsShell({
<div>
<div className="text-xs font-medium text-gray-600 mb-1">Events</div>
<EventsDropdown options={events.map(ev => ({ value: ev.id, label: ev.title }))} value={selectedEventIds} onChange={setSelectedEventIds} />
<EventsDropdown options={events.map(ev => ({ value: ev.id, label: ev.title, hint: ev.startDate ? new Date(ev.startDate).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }) : undefined }))} value={selectedEventIds} onChange={setSelectedEventIds} />
</div>
<div className="space-y-1.5 text-sm">
@@ -0,0 +1,259 @@
"use client";
import React, { useEffect, useId, useMemo, useRef, useState } from "react";
import { ChevronDown, Search, X } from "lucide-react";
export type PickerEvent = {
id: string;
title: string;
startDate: string;
endDate?: string | null;
campus?: { name: string } | null;
isActive?: boolean;
};
// Search results shown at once; typing more narrows them.
const MAX_RESULTS = 60;
const shortDate = (d: string) => new Date(d).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
const isPast = (ev: PickerEvent, cutoff: number) => new Date(ev.endDate || ev.startDate).getTime() < cutoff;
/**
* A searchable event chooser in place of a long <select>. Opens on upcoming events (soonest
* first); past events (latest first) are one click away, and typing searches every event it
* was given by title, campus or date. `emptyLabel` adds a "no event" choice (e.g. "All events").
*/
export function EventPicker<E extends PickerEvent>({ events, value, onChange, emptyLabel, placeholder = "Choose an event", label = "Event", id, className = "", disabled = false, graceHours = 0, describe }: {
events: E[];
value: string;
onChange: (id: string, event: E | null) => void;
emptyLabel?: string;
placeholder?: string;
// Accessible name (visually hidden unless the page gives its own <label htmlFor={id}>).
label?: string;
id?: string;
className?: string;
disabled?: boolean;
// An event still counts as current this many hours after it ends (e.g. late scanning).
graceHours?: number;
// Extra detail after the date in the list, e.g. "3 roles".
describe?: (ev: E) => string | null | undefined;
}) {
const autoId = useId();
const buttonId = id || `event-picker-${autoId}`;
const listId = `${buttonId}-list`;
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [showPast, setShowPast] = useState(false);
const [active, setActive] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const selected = events.find(e => e.id === value) || null;
const { upcoming, past } = useMemo(() => {
const cutoff = Date.now() - graceHours * 3600e3;
const up: E[] = [];
const old: E[] = [];
for (const ev of events) (isPast(ev, cutoff) ? old : up).push(ev);
up.sort((a, b) => +new Date(a.startDate) - +new Date(b.startDate));
old.sort((a, b) => +new Date(b.startDate) - +new Date(a.startDate));
return { upcoming: up, past: old };
}, [events, graceHours]);
// What the list shows: [heading | event | "none"] rows. Only events and "none" are choosable.
const { rows, hidden } = useMemo(() => {
type Row = { kind: "head"; text: string } | { kind: "none" } | { kind: "event"; ev: E; past: boolean };
const out: Row[] = [];
const q = query.trim().toLowerCase();
if (q) {
const words = q.split(/\s+/);
const hit = (ev: E) => {
const hay = `${ev.title} ${ev.campus?.name || ""} ${shortDate(ev.startDate)}`.toLowerCase();
return words.every(w => hay.includes(w));
};
const up = upcoming.filter(hit);
const old = past.filter(hit);
const all = [...up.map(ev => ({ ev, past: false })), ...old.map(ev => ({ ev, past: true }))];
all.slice(0, MAX_RESULTS).forEach(r => out.push({ kind: "event", ...r }));
return { rows: out, hidden: Math.max(0, all.length - MAX_RESULTS) };
}
if (emptyLabel) out.push({ kind: "none" });
if (upcoming.length) {
if (past.length) out.push({ kind: "head", text: "Upcoming" });
upcoming.forEach(ev => out.push({ kind: "event", ev, past: false }));
}
if (showPast && past.length) {
out.push({ kind: "head", text: "Past" });
past.forEach(ev => out.push({ kind: "event", ev, past: true }));
}
return { rows: out, hidden: 0 };
}, [query, upcoming, past, showPast, emptyLabel]);
const choosable = rows.map((r, i) => (r.kind === "head" ? -1 : i)).filter(i => i >= 0);
// Open on the chosen event; a past one shows the past list too.
const openList = () => {
if (disabled) return;
setQuery("");
const pastPick = !!selected && past.some(e => e.id === selected.id);
setShowPast(pastPick);
setOpen(true);
};
useEffect(() => {
if (!open) return;
inputRef.current?.focus();
const at = rows.findIndex(r => (r.kind === "event" ? r.ev.id === value : r.kind === "none" && !value));
setActive(at >= 0 ? at : (choosable[0] ?? 0));
// Only when it opens.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
useEffect(() => {
listRef.current?.querySelector(`[data-row="${active}"]`)?.scrollIntoView({ block: "nearest" });
}, [active, open]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => { if (!rootRef.current?.contains(e.target as Node)) setOpen(false); };
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const close = () => { setOpen(false); buttonRef.current?.focus(); };
const choose = (i: number) => {
const r = rows[i];
if (!r || r.kind === "head") return;
if (r.kind === "none") onChange("", null);
else onChange(r.ev.id, r.ev);
close();
};
const move = (step: number) => {
if (!choosable.length) return;
const at = choosable.indexOf(active);
const next = at < 0 ? 0 : Math.min(choosable.length - 1, Math.max(0, at + step));
setActive(choosable[next]);
};
const onKey = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") { e.preventDefault(); move(1); }
else if (e.key === "ArrowUp") { e.preventDefault(); move(-1); }
else if (e.key === "PageDown") { e.preventDefault(); move(10); }
else if (e.key === "PageUp") { e.preventDefault(); move(-10); }
else if (e.key === "Enter") { e.preventDefault(); choose(active); }
else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); close(); }
else if (e.key === "Tab") setOpen(false);
};
const buttonText = selected ? selected.title : (emptyLabel || placeholder);
return (
<div ref={rootRef} className={`relative inline-block min-w-[16rem] max-w-full ${className}`} data-testid="event-picker">
<button
ref={buttonRef}
id={buttonId}
type="button"
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={`${label}: ${buttonText}`}
onClick={() => (open ? setOpen(false) : openList())}
onKeyDown={e => { if (e.key === "ArrowDown" && !open) { e.preventDefault(); openList(); } }}
className="w-full flex items-center gap-2 border rounded-lg px-3 py-2 text-sm bg-white text-left hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className={`flex-1 truncate ${selected ? "text-gray-900" : "text-gray-600"}`}>
{buttonText}
{selected && <span className="text-gray-500"> · {shortDate(selected.startDate)}</span>}
</span>
<ChevronDown className="w-4 h-4 text-gray-500 shrink-0" aria-hidden />
</button>
{open && (
<div className="absolute z-40 mt-1 w-full min-w-[18rem] sm:min-w-[24rem] rounded-lg border bg-white shadow-lg">
<div className="flex items-center gap-2 border-b px-3">
<Search className="w-4 h-4 text-gray-500 shrink-0" aria-hidden />
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded="true"
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={rows[active] && rows[active].kind !== "head" ? `${listId}-${active}` : undefined}
aria-label={`Search ${label.toLowerCase()}s`}
placeholder="Search by name, campus or date…"
className="flex-1 py-2 text-sm outline-none bg-transparent"
value={query}
onChange={e => { setQuery(e.target.value); setActive(0); }}
onKeyDown={onKey}
/>
{query && (
<button type="button" aria-label="Clear search" className="text-gray-500 hover:text-gray-800" onClick={() => { setQuery(""); inputRef.current?.focus(); }}>
<X className="w-4 h-4" aria-hidden />
</button>
)}
</div>
<ul ref={listRef} id={listId} role="listbox" aria-label={`${label}s`} className="max-h-72 overflow-y-auto py-1">
{rows.map((r, i) => {
if (r.kind === "head") {
return <li key={`h-${r.text}`} role="presentation" className="px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-gray-500">{r.text}</li>;
}
const isSel = r.kind === "none" ? !value : r.ev.id === value;
return (
<li
key={r.kind === "none" ? "none" : r.ev.id}
id={`${listId}-${i}`}
data-row={i}
role="option"
aria-selected={isSel}
onMouseDown={e => e.preventDefault()}
onClick={() => choose(i)}
onMouseMove={() => active !== i && setActive(i)}
className={`px-3 py-1.5 text-sm cursor-pointer ${i === active ? "bg-brand-50" : ""} ${isSel ? "font-medium" : ""}`}
>
{r.kind === "none" ? (
<span className="text-gray-700">{emptyLabel}</span>
) : (
<span className="flex items-baseline gap-2">
<span className="flex-1 min-w-0 truncate text-gray-900">{r.ev.title}</span>
<span className="shrink-0 text-xs text-gray-600">
{r.ev.campus?.name ? `${r.ev.campus.name} · ` : ""}{shortDate(r.ev.startDate)}{r.past && query ? " · past" : ""}{r.ev.isActive === false ? " · inactive" : ""}{describe?.(r.ev) ? ` · ${describe(r.ev)}` : ""}
</span>
</span>
)}
</li>
);
})}
{!rows.some(r => r.kind === "event") && (
<li role="presentation" className="px-3 py-2 text-sm text-gray-500">
{query ? "No events match." : past.length ? "No upcoming events." : "No events."}
</li>
)}
</ul>
<div className="flex items-center justify-between gap-2 border-t px-3 py-1.5 text-xs text-gray-600" aria-live="polite">
<span>
{query
? `${rows.filter(r => r.kind === "event").length + hidden} match${rows.filter(r => r.kind === "event").length + hidden === 1 ? "" : "es"}${hidden ? ` · showing the first ${MAX_RESULTS}, keep typing` : ""}`
: `${upcoming.length} upcoming${showPast ? ` · ${past.length} past` : ""}`}
</span>
{!query && past.length > 0 && (
<button type="button" className="text-brand-700 hover:underline" onClick={() => { setShowPast(s => !s); inputRef.current?.focus(); }}>
{showPast ? "Hide past events" : `Show past events (${past.length})`}
</button>
)}
</div>
</div>
)}
</div>
);
}
/** The event a page opens on: the next one coming up, or else the most recent. */
export function defaultEventId(events: PickerEvent[], now = Date.now()): string {
let next: PickerEvent | null = null;
let last: PickerEvent | null = null;
for (const ev of events) {
const start = new Date(ev.startDate).getTime();
if (!isPast(ev, now)) { if (!next || start < new Date(next.startDate).getTime()) next = ev; }
else if (!last || start > new Date(last.startDate).getTime()) last = ev;
}
return (next || last)?.id || "";
}
+4 -1
View File
@@ -1,5 +1,5 @@
import React from "react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play, CreditCard, ArrowUpDown, KeyRound, ArrowUpCircle, MapPinned, Archive, Users, BellOff, Megaphone, Webhook, Send } from "lucide-react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play, CreditCard, ArrowUpDown, KeyRound, ArrowUpCircle, MapPinned, Archive, Users, BellOff, Megaphone, Webhook, Send, BookOpen } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -164,6 +164,9 @@ export const siteSettingsHelpContent: HelpContent = {
<GuideItem icon={Send} title="Test and delivery log" tone="green">
&quot;Send test&quot; sends a test message straight away. The delivery log shows everything sent in the last 30 days, what came back, and the full message. Anything that fails is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours; &quot;Resend&quot; tries again now.
</GuideItem>
<GuideItem icon={BookOpen} title="How webhooks work" tone="gray">
Open &quot;How webhooks work&quot; on the Webhooks tab for whoever sets up the receiving side: what is sent and when, the headers, how to check the signature (with Node.js and PHP examples), and an example message for every event type that you can copy. It opens by itself until you&apos;ve added a webhook.
</GuideItem>
<GuideItem icon={Scale} title="Privacy" tone="amber">
Webhooks send names, email addresses and phone numbers, so each webhook is added to your operator register (Admin → Privacy) — have an agreement with the service. Children&apos;s details from kids check-in are never sent. Switch a webhook off or delete it at any time.
</GuideItem>
+27 -15
View File
@@ -2,7 +2,15 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { apiFetch, ApiError } from "@/lib/api";
import { AuthResponse, AuthUser, TwoFactorChallenge, clearToken, getDeviceToken, getToken, isTwoFactorChallenge, saveDeviceToken, saveToken } from "@/lib/auth";
import { AuthResponse, AuthUser, TwoFactorChallenge, clearToken, getDeviceToken, getToken, saveDeviceToken, saveToken } from "@/lib/auth";
import { apiV2, type V2Result } from "@/lib/apiV2";
// A v2 "signed_in" answer as the session the app keeps (the user's fields plus the token).
type V2Session = Extract<V2Result<"/auth/login", "post">["data"], { status: "signed_in" }>;
const toAuthResponse = (s: V2Session): AuthResponse => ({
...s.user, token: s.token, newsPromptPending: s.newsPromptPending, noticePromptPending: s.noticePromptPending,
...(s.deviceToken ? { deviceToken: s.deviceToken } : {}),
});
export type AuthContextValue = {
user: AuthUser | null;
@@ -91,22 +99,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}, []);
const login = useCallback(async (email: string, password: string) => {
const res = await apiFetch<AuthResponse | TwoFactorChallenge>("/api/users/login", {
method: "POST",
body: { email, password, deviceToken: getDeviceToken() || undefined },
const { data } = await apiV2("post", "/auth/login", {
body: { identifier: email, password, deviceToken: getDeviceToken() || undefined },
});
// 2FA accounts get a challenge back instead of a token — nothing is saved
// until verifyTwoFactor exchanges the emailed/WhatsApp code for one.
if (isTwoFactorChallenge(res)) return res;
return startSession(res as AuthResponse);
if (data.status === "code_required") {
const challenge: TwoFactorChallenge = { twoFactorRequired: true, challengeId: data.challengeId, method: data.method, destination: data.destination || "", ...(data.enrolling ? { enrolling: true } : {}) };
return challenge;
}
return startSession(toAuthResponse(data));
}, [startSession]);
const verifyTwoFactor = useCallback(async (challengeId: string, code: string, rememberDevice: boolean) => {
const res = await apiFetch<AuthResponse>("/api/users/login/2fa", {
method: "POST",
body: { challengeId, code, rememberDevice },
});
return startSession(res);
const { data } = await apiV2("post", "/auth/login/code", { body: { challengeId, code, rememberDevice } });
return startSession(toAuthResponse(data));
}, [startSession]);
const resendTwoFactor = useCallback((challengeId: string) => {
@@ -117,11 +124,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}, []);
const register = useCallback(async (data: { name: string; email: string; password: string; phoneNumber?: string; notificationPreference?: "email" | "whatsapp" | "both"; newsOptIn?: boolean }) => {
const res = await apiFetch<AuthResponse>("/api/users", {
method: "POST",
body: data,
// The sign-up form only submits once "I agree to the Terms of Use and Privacy Policy" is ticked.
const { data: made } = await apiV2("post", "/auth/signup", {
body: {
name: data.name, email: data.email, password: data.password, acceptTerms: true,
...(data.phoneNumber ? { phone: data.phoneNumber } : {}),
...(data.notificationPreference ? { notificationPreference: data.notificationPreference } : {}),
...(data.newsOptIn !== undefined ? { newsOptIn: data.newsOptIn } : {}),
},
});
const { token, ...registeredUser } = res;
const { token, ...registeredUser } = toAuthResponse(made);
saveToken(token);
setToken(token);
setUser(registeredUser);
+1 -3
View File
@@ -1,9 +1,7 @@
// Next.js server instrumentation entry point. A no-op when NEXT_PUBLIC_SENTRY_DSN isn't
// set — see instrumentation-client.ts for the browser-side counterpart.
//
// Node runtime only, deliberately — this app's middleware.ts (which runs in Next's edge
// runtime) is a trivial pass-through with no real error surface, so an edge-runtime branch
// here would only inflate that shared middleware bundle for no actual coverage benefit.
// Node runtime only — the app has no middleware/proxy running in the edge runtime.
export async function register() {
if (!process.env.NEXT_PUBLIC_SENTRY_DSN) return;
if (process.env.NEXT_RUNTIME !== "nodejs") return;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+18 -9
View File
@@ -74,8 +74,9 @@ export function resolveToApiOrigin(urlOrPath: string | null | undefined): string
return API_BASE ? `${API_BASE}${raw}` : raw;
}
// The parsed JSON body of a failed request — the backend always sends `message`, and
// some routes add a machine-readable `code` or extra detail fields.
// The parsed JSON body of a failed request — v1 sends `message` (some routes add a
// machine-readable `code` or extra detail fields); for v2 it is the `error` object
// (`code`, `message`, and `fields` on validation errors).
export type ApiErrorData = { message?: string; code?: string; [key: string]: unknown };
export class ApiError extends Error {
@@ -122,6 +123,12 @@ export async function apiFetch<T = unknown>(path: string, options: ApiOptions =
const json = JSON.parse(text);
if (json?.message && typeof json.message === "string") message = json.message;
data = json;
// /api/v2 errors: { error: { code, message, fields?, reason? } }. `code` is the specific
// reason when there is one (TWO_FACTOR_REQUIRED, OTHER_CAMPUS…), like v1; v2Code is v2's own.
if (json?.error && typeof json.error === "object" && typeof json.error.message === "string") {
message = json.error.message;
data = { ...json.error, code: json.error.reason || json.error.code, v2Code: json.error.code };
}
} catch { /* not JSON — use raw text */ }
// An admin/supervisor session from before 2FA was required for their role:
// end it and send them to log in, where they enrol.
@@ -198,21 +205,23 @@ export function fetchAllPayments(token: string, params: Record<string, string> =
return fetchAllPages<Payment>("/api/payments", token, params);
}
// Creates an online checkout (default payment provider) for a registration's full remaining balance.
// Omitting `amount` makes the backend charge the full remainder rather than a partial amount —
// partial-amount checkouts are only ever created from the supervisor payments dashboard.
// Starts an online payment for everything still owed on one of my registrations (API v2), with
// an optional donation on top. Partial amounts are only taken from the supervisor payments page.
export type CheckoutResponse = { redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string };
export function createFullPaymentCheckout(token: string, registrationId: string, donationAmount?: number): Promise<CheckoutResponse> {
return apiFetch<CheckoutResponse>("/api/payments/checkout", {
method: "POST",
export async function createFullPaymentCheckout(token: string, registrationId: string, donationAmount?: number): Promise<CheckoutResponse> {
const { apiV2 } = await import("@/lib/apiV2");
const { data } = await apiV2("post", "/registrations/{id}/checkout", {
params: { id: registrationId },
authToken: token,
body: {
registrationId,
...(donationAmount && donationAmount > 0 ? { donationAmount } : {}),
successUrl: window.location.origin + "/payment/success",
cancelUrl: window.location.origin + "/payment/cancel",
failureUrl: window.location.origin + "/payment/failure",
},
});
// The price changed since registering: nothing was started — show the new amount owing.
if (data.priceUpdated) return { priceUpdated: true, newTotal: data.newBalance, message: data.message };
return { redirectUrl: data.redirectUrl };
}
+44
View File
@@ -0,0 +1,44 @@
import { apiFetch } from "@/lib/api";
import type { paths } from "@/lib/api-v2/schema";
/**
* Typed calls to /api/v2. The types come from the backend's OpenAPI document
* (`npm run api:types` in the repo root regenerates src/lib/api-v2/), so a path, its parameters,
* its body and its answer are all checked by TypeScript:
*
* const { data, meta } = await apiV2("get", "/events", { query: { q: "camp", limit: 10 } });
* const { data } = await apiV2("post", "/registrations/{id}/cancel", { params: { id }, authToken });
*
* Errors throw ApiError (from apiFetch) with the v2 `code` and `fields` in `.data`.
*/
type Method = "get" | "post" | "put" | "patch" | "delete";
type PathsWith<M extends Method> = { [P in keyof paths]: paths[P][M] extends { responses: unknown } ? P : never }[keyof paths];
type Op<P extends keyof paths, M extends Method> = NonNullable<paths[P][M]>;
type Json<T> = T extends { content: { "application/json": infer J } } ? J : never;
type Responses<O> = O extends { responses: infer R } ? R : never;
/** The 2xx answer's JSON. */
export type V2Result<P extends keyof paths, M extends Method> = Json<Responses<Op<P, M>>[Extract<keyof Responses<Op<P, M>>, 200 | 201>]>;
type PathParams<O> = O extends { parameters: { path: infer X } } ? (X extends Record<string, unknown> ? X : never) : never;
type QueryParams<O> = O extends { parameters: { query?: infer X } } ? X : never;
type Body<O> = O extends { requestBody: { content: { "application/json": infer B } } } ? B : never;
type IfAny<T, Then> = [T] extends [never] ? object : Then;
export type V2Options<P extends keyof paths, M extends Method> =
{ authToken?: string | null }
& IfAny<PathParams<Op<P, M>>, { params: PathParams<Op<P, M>> }>
& IfAny<QueryParams<Op<P, M>>, { query?: QueryParams<Op<P, M>> }>
& IfAny<Body<Op<P, M>>, { body: Body<Op<P, M>> }>;
export function apiV2<M extends Method, P extends PathsWith<M>>(method: M, path: P, ...[options]: object extends V2Options<P, M> ? [V2Options<P, M>?] : [V2Options<P, M>]): Promise<V2Result<P, M>> {
const opts = (options || {}) as { authToken?: string | null; params?: Record<string, string>; query?: Record<string, unknown>; body?: unknown };
let url = String(path).replace(/\{(\w+)\}/g, (_, name: string) => encodeURIComponent(String(opts.params?.[name] ?? "")));
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(opts.query || {})) {
if (v !== undefined && v !== null && v !== "") qs.set(k, String(v));
}
if (qs.toString()) url += `?${qs.toString()}`;
return apiFetch<V2Result<P, M>>(`/api/v2${url}`, { method: method.toUpperCase(), authToken: opts.authToken, body: opts.body });
}
+18 -7
View File
@@ -1,5 +1,8 @@
// What the Ctrl+K command palette can jump to without searching (1.49.0): pages, settings tabs,
// reports and quick actions, each for the roles that can open it. `keywords` widen matching.
// Reports come from the report catalogue, so a new report shows up here by itself; a new page
// needs a line below (e2e/dashboard.spec.ts checks every dashboard page is listed or excluded).
import { REPORTS } from "@/components/reports/ReportCatalog";
export type Role = "staff" | "supervisor" | "admin";
export type CommandPage = { label: string; href: string; group: "Actions" | "Pages" | "Settings" | "Reports"; roles: Role[]; keywords?: string };
@@ -18,6 +21,10 @@ export const COMMAND_PAGES: CommandPage[] = [
{ group: "Actions", label: "Email attendees", href: "/dashboard/supervisor/email-attendees", roles: SUP, keywords: "message send mail" },
{ group: "Actions", label: "WhatsApp attendees", href: "/dashboard/supervisor/whatsapp-attendees", roles: SUP, keywords: "message send" },
// Pages
{ group: "Pages", label: "Dashboard", href: "/dashboard", roles: S, keywords: "home today waiting overview" },
{ group: "Pages", label: "Reports", href: "/dashboard/supervisor/reports", roles: SUP, keywords: "all reports export pdf excel" },
{ group: "Pages", label: "Ticket options", href: "/dashboard/supervisor/event-options", roles: SUP, keywords: "event options prices variants early bird" },
{ group: "Pages", label: "My groups", href: "/dashboard/staff/my-groups", roles: S, keywords: "rooms groups leader allocations" },
{ group: "Pages", label: "Events", href: "/dashboard/supervisor/events", roles: SUP, keywords: "manage edit copy" },
{ group: "Pages", label: "Event series", href: "/dashboard/supervisor/series", roles: SUP, keywords: "season pass follow" },
{ group: "Pages", label: "Registrations", href: "/dashboard/admin/registrations", roles: ADM, keywords: "orders cancel status" },
@@ -37,23 +44,27 @@ export const COMMAND_PAGES: CommandPage[] = [
{ group: "Pages", label: "Bundle deals", href: "/dashboard/supervisor/bundles", roles: SUP, keywords: "family deal" },
{ group: "Pages", label: "Event tickets & printing", href: "/dashboard/staff/event-tickets", roles: S, keywords: "print list" },
{ group: "Pages", label: "Offline scanning", href: "/dashboard/supervisor/offline-scans", roles: SUP, keywords: "devices sync" },
{ group: "Pages", label: "Attendee forms", href: "/dashboard/admin/forms", roles: ADM, keywords: "answers" },
{ group: "Pages", label: "Attendee forms", href: "/dashboard/supervisor/forms", roles: S, keywords: "answers questions responses" },
{ group: "Pages", label: "Audit log", href: "/dashboard/admin/audit-log", roles: ADM, keywords: "history who changed" },
{ group: "Pages", label: "Privacy (POPIA)", href: "/dashboard/admin/privacy", roles: ADM, keywords: "notice operators retention breaches readiness" },
{ group: "Pages", label: "Privacy requests", href: "/dashboard/admin/data-requests", roles: ADM, keywords: "delete download correction popia" },
{ group: "Pages", label: "My profile & security", href: "/dashboard/user/profile", roles: S, keywords: "password two-factor" },
// Their own side, as members (staff register for things too)
{ group: "Pages", label: "My events & tickets", href: "/dashboard/user", roles: S, keywords: "my registrations tickets" },
{ group: "Pages", label: "My payments", href: "/dashboard/user/payments", roles: S, keywords: "my receipts invoices" },
{ group: "Pages", label: "My privacy", href: "/dashboard/user/privacy", roles: S, keywords: "my data download delete news popia" },
{ group: "Pages", label: "My kids", href: "/dashboard/user/kids", roles: S, keywords: "children family pickup" },
{ group: "Pages", label: "My serving", href: "/dashboard/user/serving", roles: S, keywords: "volunteer roster cover" },
{ group: "Pages", label: "My attendee forms", href: "/dashboard/user/forms", roles: S, keywords: "fill in answers" },
{ group: "Pages", label: "Make a donation", href: "/dashboard/user/donate", roles: S, keywords: "give donate" },
// Settings
...([
["organisation", "Organisation"], ["campuses", "Campuses"], ["branding", "Branding"], ["notifications", "Notifications"],
["email", "Email"], ["legal", "Legal"], ["whatsapp", "WhatsApp"], ["payments", "Payments"], ["webhooks", "Webhooks"],
["backups", "Backups"], ["jobs", "Background jobs"], ["licence", "Licence & updates"],
] as const).map(([tab, label]): CommandPage => ({ group: "Settings", label: `Settings: ${label}`, href: `/dashboard/admin/settings?tab=${tab}`, roles: ADM, keywords: "site settings" })),
// Reports
...([
["payments", "Payments between dates"], ["revenue", "Revenue summary"], ["regStatus", "Registration status"],
["funnel", "Registration funnel"], ["feedbackSummary", "Feedback summary"], ["usage", "Ticket usage"],
["financeReport", "Finance report"], ["seriesComparison", "Series: year on year"], ["discounts", "Discounts"],
] as const).map(([key, label]): CommandPage => ({ group: "Reports", label: `Report: ${label}`, href: `/dashboard/supervisor/reports?report=${key}`, roles: SUP, keywords: "report" })),
// Reports — every report in the catalogue
...REPORTS.map((r): CommandPage => ({ group: "Reports", label: `Report: ${r.label}`, href: `/dashboard/supervisor/reports?report=${r.key}`, roles: SUP, keywords: `report ${r.category} ${r.description}` })),
];
/** Pages for a role, matching every word of the query (in the label or keywords). */
-6
View File
@@ -1,6 +0,0 @@
import { NextResponse } from "next/server";
// Minimal pass-through middleware to satisfy Next.js requirements
export function middleware() {
return NextResponse.next();
}
+2 -2
View File
@@ -11,7 +11,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -22,6 +22,6 @@
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next-e2e/types/**/*.ts"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", ".next-e2e/types/**/*.ts", ".next-e2e/dev/types/**/*.ts"],
"exclude": ["node_modules"]
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.50.1",
"version": "1.51.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.50.1",
"version": "1.51.6",
"license": "ISC",
"devDependencies": {
"@axe-core/playwright": "^4.14.0",

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